diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source')
88 files changed, 22988 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java new file mode 100644 index 0000000000..1f67f7e645 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** Abstract base class for the concatenation of one or more {@link Timeline}s. */ +/* package */ abstract class AbstractConcatenatedTimeline extends Timeline { + + private final int childCount; + private final ShuffleOrder shuffleOrder; + private final boolean isAtomic; + + /** + * Returns UID of child timeline from a concatenated period UID. + * + * @param concatenatedUid UID of a period in a concatenated timeline. + * @return UID of the child timeline this period belongs to. + */ + @SuppressWarnings("nullness:return.type.incompatible") + public static Object getChildTimelineUidFromConcatenatedUid(Object concatenatedUid) { + return ((Pair<?, ?>) concatenatedUid).first; + } + + /** + * Returns UID of the period in the child timeline from a concatenated period UID. + * + * @param concatenatedUid UID of a period in a concatenated timeline. + * @return UID of the period in the child timeline. + */ + @SuppressWarnings("nullness:return.type.incompatible") + public static Object getChildPeriodUidFromConcatenatedUid(Object concatenatedUid) { + return ((Pair<?, ?>) concatenatedUid).second; + } + + /** + * Returns a concatenated UID for a period or window in a child timeline. + * + * @param childTimelineUid UID of the child timeline this period or window belongs to. + * @param childPeriodOrWindowUid UID of the period or window in the child timeline. + * @return UID of the period or window in the concatenated timeline. + */ + public static Object getConcatenatedUid(Object childTimelineUid, Object childPeriodOrWindowUid) { + return Pair.create(childTimelineUid, childPeriodOrWindowUid); + } + + /** + * Sets up a concatenated timeline with a shuffle order of child timelines. + * + * @param isAtomic Whether the child timelines shall be treated as atomic, i.e., treated as a + * single item for repeating and shuffling. + * @param shuffleOrder A shuffle order of child timelines. The number of child timelines must + * match the number of elements in the shuffle order. + */ + public AbstractConcatenatedTimeline(boolean isAtomic, ShuffleOrder shuffleOrder) { + this.isAtomic = isAtomic; + this.shuffleOrder = shuffleOrder; + this.childCount = shuffleOrder.getLength(); + } + + @Override + public int getNextWindowIndex( + int windowIndex, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + if (isAtomic) { + // Adapt repeat and shuffle mode to atomic concatenation. + repeatMode = repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_ALL : repeatMode; + shuffleModeEnabled = false; + } + // Find next window within current child. + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int nextWindowIndexInChild = + getTimelineByChildIndex(childIndex) + .getNextWindowIndex( + windowIndex - firstWindowIndexInChild, + repeatMode == Player.REPEAT_MODE_ALL ? Player.REPEAT_MODE_OFF : repeatMode, + shuffleModeEnabled); + if (nextWindowIndexInChild != C.INDEX_UNSET) { + return firstWindowIndexInChild + nextWindowIndexInChild; + } + // If not found, find first window of next non-empty child. + int nextChildIndex = getNextChildIndex(childIndex, shuffleModeEnabled); + while (nextChildIndex != C.INDEX_UNSET && getTimelineByChildIndex(nextChildIndex).isEmpty()) { + nextChildIndex = getNextChildIndex(nextChildIndex, shuffleModeEnabled); + } + if (nextChildIndex != C.INDEX_UNSET) { + return getFirstWindowIndexByChildIndex(nextChildIndex) + + getTimelineByChildIndex(nextChildIndex).getFirstWindowIndex(shuffleModeEnabled); + } + // If not found, this is the last window. + if (repeatMode == Player.REPEAT_MODE_ALL) { + return getFirstWindowIndex(shuffleModeEnabled); + } + return C.INDEX_UNSET; + } + + @Override + public int getPreviousWindowIndex( + int windowIndex, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + if (isAtomic) { + // Adapt repeat and shuffle mode to atomic concatenation. + repeatMode = repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_ALL : repeatMode; + shuffleModeEnabled = false; + } + // Find previous window within current child. + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int previousWindowIndexInChild = + getTimelineByChildIndex(childIndex) + .getPreviousWindowIndex( + windowIndex - firstWindowIndexInChild, + repeatMode == Player.REPEAT_MODE_ALL ? Player.REPEAT_MODE_OFF : repeatMode, + shuffleModeEnabled); + if (previousWindowIndexInChild != C.INDEX_UNSET) { + return firstWindowIndexInChild + previousWindowIndexInChild; + } + // If not found, find last window of previous non-empty child. + int previousChildIndex = getPreviousChildIndex(childIndex, shuffleModeEnabled); + while (previousChildIndex != C.INDEX_UNSET + && getTimelineByChildIndex(previousChildIndex).isEmpty()) { + previousChildIndex = getPreviousChildIndex(previousChildIndex, shuffleModeEnabled); + } + if (previousChildIndex != C.INDEX_UNSET) { + return getFirstWindowIndexByChildIndex(previousChildIndex) + + getTimelineByChildIndex(previousChildIndex).getLastWindowIndex(shuffleModeEnabled); + } + // If not found, this is the first window. + if (repeatMode == Player.REPEAT_MODE_ALL) { + return getLastWindowIndex(shuffleModeEnabled); + } + return C.INDEX_UNSET; + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + if (childCount == 0) { + return C.INDEX_UNSET; + } + if (isAtomic) { + shuffleModeEnabled = false; + } + // Find last non-empty child. + int lastChildIndex = shuffleModeEnabled ? shuffleOrder.getLastIndex() : childCount - 1; + while (getTimelineByChildIndex(lastChildIndex).isEmpty()) { + lastChildIndex = getPreviousChildIndex(lastChildIndex, shuffleModeEnabled); + if (lastChildIndex == C.INDEX_UNSET) { + // All children are empty. + return C.INDEX_UNSET; + } + } + return getFirstWindowIndexByChildIndex(lastChildIndex) + + getTimelineByChildIndex(lastChildIndex).getLastWindowIndex(shuffleModeEnabled); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + if (childCount == 0) { + return C.INDEX_UNSET; + } + if (isAtomic) { + shuffleModeEnabled = false; + } + // Find first non-empty child. + int firstChildIndex = shuffleModeEnabled ? shuffleOrder.getFirstIndex() : 0; + while (getTimelineByChildIndex(firstChildIndex).isEmpty()) { + firstChildIndex = getNextChildIndex(firstChildIndex, shuffleModeEnabled); + if (firstChildIndex == C.INDEX_UNSET) { + // All children are empty. + return C.INDEX_UNSET; + } + } + return getFirstWindowIndexByChildIndex(firstChildIndex) + + getTimelineByChildIndex(firstChildIndex).getFirstWindowIndex(shuffleModeEnabled); + } + + @Override + public final Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex) + .getWindow(windowIndex - firstWindowIndexInChild, window, defaultPositionProjectionUs); + Object childUid = getChildUidByChildIndex(childIndex); + // Don't create new objects if the child is using SINGLE_WINDOW_UID. + window.uid = + Window.SINGLE_WINDOW_UID.equals(window.uid) + ? childUid + : getConcatenatedUid(childUid, window.uid); + window.firstPeriodIndex += firstPeriodIndexInChild; + window.lastPeriodIndex += firstPeriodIndexInChild; + return window; + } + + @Override + public final Period getPeriodByUid(Object uid, Period period) { + Object childUid = getChildTimelineUidFromConcatenatedUid(uid); + Object periodUid = getChildPeriodUidFromConcatenatedUid(uid); + int childIndex = getChildIndexByChildUid(childUid); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex).getPeriodByUid(periodUid, period); + period.windowIndex += firstWindowIndexInChild; + period.uid = uid; + return period; + } + + @Override + public final Period getPeriod(int periodIndex, Period period, boolean setIds) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex) + .getPeriod(periodIndex - firstPeriodIndexInChild, period, setIds); + period.windowIndex += firstWindowIndexInChild; + if (setIds) { + period.uid = + getConcatenatedUid( + getChildUidByChildIndex(childIndex), Assertions.checkNotNull(period.uid)); + } + return period; + } + + @Override + public final int getIndexOfPeriod(Object uid) { + if (!(uid instanceof Pair)) { + return C.INDEX_UNSET; + } + Object childUid = getChildTimelineUidFromConcatenatedUid(uid); + Object periodUid = getChildPeriodUidFromConcatenatedUid(uid); + int childIndex = getChildIndexByChildUid(childUid); + if (childIndex == C.INDEX_UNSET) { + return C.INDEX_UNSET; + } + int periodIndexInChild = getTimelineByChildIndex(childIndex).getIndexOfPeriod(periodUid); + return periodIndexInChild == C.INDEX_UNSET + ? C.INDEX_UNSET + : getFirstPeriodIndexByChildIndex(childIndex) + periodIndexInChild; + } + + @Override + public final Object getUidOfPeriod(int periodIndex) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + Object periodUidInChild = + getTimelineByChildIndex(childIndex).getUidOfPeriod(periodIndex - firstPeriodIndexInChild); + return getConcatenatedUid(getChildUidByChildIndex(childIndex), periodUidInChild); + } + + /** + * Returns the index of the child timeline containing the given period index. + * + * @param periodIndex A valid period index within the bounds of the timeline. + */ + protected abstract int getChildIndexByPeriodIndex(int periodIndex); + + /** + * Returns the index of the child timeline containing the given window index. + * + * @param windowIndex A valid window index within the bounds of the timeline. + */ + protected abstract int getChildIndexByWindowIndex(int windowIndex); + + /** + * Returns the index of the child timeline with the given UID or {@link C#INDEX_UNSET} if not + * found. + * + * @param childUid A child UID. + * @return Index of child timeline or {@link C#INDEX_UNSET} if UID was not found. + */ + protected abstract int getChildIndexByChildUid(Object childUid); + + /** + * Returns the child timeline for the child with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract Timeline getTimelineByChildIndex(int childIndex); + + /** + * Returns the first period index belonging to the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract int getFirstPeriodIndexByChildIndex(int childIndex); + + /** + * Returns the first window index belonging to the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract int getFirstWindowIndexByChildIndex(int childIndex); + + /** + * Returns the UID of the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract Object getChildUidByChildIndex(int childIndex); + + private int getNextChildIndex(int childIndex, boolean shuffleModeEnabled) { + return shuffleModeEnabled + ? shuffleOrder.getNextIndex(childIndex) + : childIndex < childCount - 1 ? childIndex + 1 : C.INDEX_UNSET; + } + + private int getPreviousChildIndex(int childIndex, boolean shuffleModeEnabled) { + return shuffleModeEnabled + ? shuffleOrder.getPreviousIndex(childIndex) + : childIndex > 0 ? childIndex - 1 : C.INDEX_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java new file mode 100644 index 0000000000..dba911f622 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * Interface for callbacks to be notified of {@link MediaSource} events. + * + * @deprecated Use {@link MediaSourceEventListener}. + */ +@Deprecated +public interface AdaptiveMediaSourceEventListener extends MediaSourceEventListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java new file mode 100644 index 0000000000..f9ca6ff311 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayList; +import java.util.HashSet; + +/** + * Base {@link MediaSource} implementation to handle parallel reuse and to keep a list of {@link + * MediaSourceEventListener}s. + * + * <p>Whenever an implementing subclass needs to provide a new timeline, it must call {@link + * #refreshSourceInfo(Timeline)} to notify all listeners. + */ +public abstract class BaseMediaSource implements MediaSource { + + private final ArrayList<MediaSourceCaller> mediaSourceCallers; + private final HashSet<MediaSourceCaller> enabledMediaSourceCallers; + private final MediaSourceEventListener.EventDispatcher eventDispatcher; + + @Nullable private Looper looper; + @Nullable private Timeline timeline; + + public BaseMediaSource() { + mediaSourceCallers = new ArrayList<>(/* initialCapacity= */ 1); + enabledMediaSourceCallers = new HashSet<>(/* initialCapacity= */ 1); + eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + } + + /** + * Starts source preparation and enables the source, see {@link #prepareSource(MediaSourceCaller, + * TransferListener)}. This method is called at most once until the next call to {@link + * #releaseSourceInternal()}. + * + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. Note that this listener should usually + * be only informed of transfers related to the media loads and not of auxiliary loads for + * manifests and other data. + */ + protected abstract void prepareSourceInternal(@Nullable TransferListener mediaTransferListener); + + /** Enables the source, see {@link #enable(MediaSourceCaller)}. */ + protected void enableInternal() {} + + /** Disables the source, see {@link #disable(MediaSourceCaller)}. */ + protected void disableInternal() {} + + /** + * Releases the source, see {@link #releaseSource(MediaSourceCaller)}. This method is called + * exactly once after each call to {@link #prepareSourceInternal(TransferListener)}. + */ + protected abstract void releaseSourceInternal(); + + /** + * Updates timeline and manifest and notifies all listeners of the update. + * + * @param timeline The new {@link Timeline}. + */ + protected final void refreshSourceInfo(Timeline timeline) { + this.timeline = timeline; + for (MediaSourceCaller caller : mediaSourceCallers) { + caller.onSourceInfoRefreshed(/* source= */ this, timeline); + } + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified media period id. + * + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @return An event dispatcher with pre-configured media period id. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + @Nullable MediaPeriodId mediaPeriodId) { + return eventDispatcher.withParameters( + /* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0); + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified media period id and time offset. + * + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return An event dispatcher with pre-configured media period id and time offset. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + Assertions.checkArgument(mediaPeriodId != null); + return eventDispatcher.withParameters(/* windowIndex= */ 0, mediaPeriodId, mediaTimeOffsetMs); + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified window index, media period id and time offset. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return An event dispatcher with pre-configured media period id and time offset. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + return eventDispatcher.withParameters(windowIndex, mediaPeriodId, mediaTimeOffsetMs); + } + + /** Returns whether the source is enabled. */ + protected final boolean isEnabled() { + return !enabledMediaSourceCallers.isEmpty(); + } + + @Override + public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + eventDispatcher.addEventListener(handler, eventListener); + } + + @Override + public final void removeEventListener(MediaSourceEventListener eventListener) { + eventDispatcher.removeEventListener(eventListener); + } + + @Override + public final void prepareSource( + MediaSourceCaller caller, @Nullable TransferListener mediaTransferListener) { + Looper looper = Looper.myLooper(); + Assertions.checkArgument(this.looper == null || this.looper == looper); + Timeline timeline = this.timeline; + mediaSourceCallers.add(caller); + if (this.looper == null) { + this.looper = looper; + enabledMediaSourceCallers.add(caller); + prepareSourceInternal(mediaTransferListener); + } else if (timeline != null) { + enable(caller); + caller.onSourceInfoRefreshed(/* source= */ this, timeline); + } + } + + @Override + public final void enable(MediaSourceCaller caller) { + Assertions.checkNotNull(looper); + boolean wasDisabled = enabledMediaSourceCallers.isEmpty(); + enabledMediaSourceCallers.add(caller); + if (wasDisabled) { + enableInternal(); + } + } + + @Override + public final void disable(MediaSourceCaller caller) { + boolean wasEnabled = !enabledMediaSourceCallers.isEmpty(); + enabledMediaSourceCallers.remove(caller); + if (wasEnabled && enabledMediaSourceCallers.isEmpty()) { + disableInternal(); + } + } + + @Override + public final void releaseSource(MediaSourceCaller caller) { + mediaSourceCallers.remove(caller); + if (mediaSourceCallers.isEmpty()) { + looper = null; + timeline = null; + enabledMediaSourceCallers.clear(); + releaseSourceInternal(); + } else { + disable(caller); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java new file mode 100644 index 0000000000..d5eeeb89a6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import java.io.IOException; + +/** + * Thrown when a live playback falls behind the available media window. + */ +public final class BehindLiveWindowException extends IOException { + + public BehindLiveWindowException() { + super(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java new file mode 100644 index 0000000000..7467d946cc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -0,0 +1,345 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Wraps a {@link MediaPeriod} and clips its {@link SampleStream}s to provide a subsequence of their + * samples. + */ +public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + /** + * The {@link MediaPeriod} wrapped by this clipping media period. + */ + public final MediaPeriod mediaPeriod; + + @Nullable private MediaPeriod.Callback callback; + private @NullableType ClippingSampleStream[] sampleStreams; + private long pendingInitialDiscontinuityPositionUs; + /* package */ long startUs; + /* package */ long endUs; + + /** + * Creates a new clipping media period that provides a clipped view of the specified {@link + * MediaPeriod}'s sample streams. + * + * <p>If the start point is guaranteed to be a key frame, pass {@code false} to {@code + * enableInitialPositionDiscontinuity} to suppress an initial discontinuity when the period is + * first read from. + * + * @param mediaPeriod The media period to clip. + * @param enableInitialDiscontinuity Whether the initial discontinuity should be enabled. + * @param startUs The clipping start time, in microseconds. + * @param endUs The clipping end time, in microseconds, or {@link C#TIME_END_OF_SOURCE} to + * indicate the end of the period. + */ + public ClippingMediaPeriod( + MediaPeriod mediaPeriod, boolean enableInitialDiscontinuity, long startUs, long endUs) { + this.mediaPeriod = mediaPeriod; + sampleStreams = new ClippingSampleStream[0]; + pendingInitialDiscontinuityPositionUs = enableInitialDiscontinuity ? startUs : C.TIME_UNSET; + this.startUs = startUs; + this.endUs = endUs; + } + + /** + * Updates the clipping start/end times for this period, in microseconds. + * + * @param startUs The clipping start time, in microseconds. + * @param endUs The clipping end time, in microseconds, or {@link C#TIME_END_OF_SOURCE} to + * indicate the end of the period. + */ + public void updateClipping(long startUs, long endUs) { + this.startUs = startUs; + this.endUs = endUs; + } + + @Override + public void prepare(MediaPeriod.Callback callback, long positionUs) { + this.callback = callback; + mediaPeriod.prepare(this, positionUs); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + mediaPeriod.maybeThrowPrepareError(); + } + + @Override + public TrackGroupArray getTrackGroups() { + return mediaPeriod.getTrackGroups(); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + sampleStreams = new ClippingSampleStream[streams.length]; + @NullableType SampleStream[] childStreams = new SampleStream[streams.length]; + for (int i = 0; i < streams.length; i++) { + sampleStreams[i] = (ClippingSampleStream) streams[i]; + childStreams[i] = sampleStreams[i] != null ? sampleStreams[i].childStream : null; + } + long enablePositionUs = + mediaPeriod.selectTracks( + selections, mayRetainStreamFlags, childStreams, streamResetFlags, positionUs); + pendingInitialDiscontinuityPositionUs = + isPendingInitialDiscontinuity() + && positionUs == startUs + && shouldKeepInitialDiscontinuity(startUs, selections) + ? enablePositionUs + : C.TIME_UNSET; + Assertions.checkState( + enablePositionUs == positionUs + || (enablePositionUs >= startUs + && (endUs == C.TIME_END_OF_SOURCE || enablePositionUs <= endUs))); + for (int i = 0; i < streams.length; i++) { + if (childStreams[i] == null) { + sampleStreams[i] = null; + } else if (sampleStreams[i] == null || sampleStreams[i].childStream != childStreams[i]) { + sampleStreams[i] = new ClippingSampleStream(childStreams[i]); + } + streams[i] = sampleStreams[i]; + } + return enablePositionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + mediaPeriod.discardBuffer(positionUs, toKeyframe); + } + + @Override + public void reevaluateBuffer(long positionUs) { + mediaPeriod.reevaluateBuffer(positionUs); + } + + @Override + public long readDiscontinuity() { + if (isPendingInitialDiscontinuity()) { + long initialDiscontinuityUs = pendingInitialDiscontinuityPositionUs; + pendingInitialDiscontinuityPositionUs = C.TIME_UNSET; + // Always read an initial discontinuity from the child, and use it if set. + long childDiscontinuityUs = readDiscontinuity(); + return childDiscontinuityUs != C.TIME_UNSET ? childDiscontinuityUs : initialDiscontinuityUs; + } + long discontinuityUs = mediaPeriod.readDiscontinuity(); + if (discontinuityUs == C.TIME_UNSET) { + return C.TIME_UNSET; + } + Assertions.checkState(discontinuityUs >= startUs); + Assertions.checkState(endUs == C.TIME_END_OF_SOURCE || discontinuityUs <= endUs); + return discontinuityUs; + } + + @Override + public long getBufferedPositionUs() { + long bufferedPositionUs = mediaPeriod.getBufferedPositionUs(); + if (bufferedPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && bufferedPositionUs >= endUs)) { + return C.TIME_END_OF_SOURCE; + } + return bufferedPositionUs; + } + + @Override + public long seekToUs(long positionUs) { + pendingInitialDiscontinuityPositionUs = C.TIME_UNSET; + for (ClippingSampleStream sampleStream : sampleStreams) { + if (sampleStream != null) { + sampleStream.clearSentEos(); + } + } + long seekUs = mediaPeriod.seekToUs(positionUs); + Assertions.checkState( + seekUs == positionUs + || (seekUs >= startUs && (endUs == C.TIME_END_OF_SOURCE || seekUs <= endUs))); + return seekUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + if (positionUs == startUs) { + // Never adjust seeks to the start of the clipped view. + return startUs; + } + SeekParameters clippedSeekParameters = clipSeekParameters(positionUs, seekParameters); + return mediaPeriod.getAdjustedSeekPositionUs(positionUs, clippedSeekParameters); + } + + @Override + public long getNextLoadPositionUs() { + long nextLoadPositionUs = mediaPeriod.getNextLoadPositionUs(); + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && nextLoadPositionUs >= endUs)) { + return C.TIME_END_OF_SOURCE; + } + return nextLoadPositionUs; + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod.continueLoading(positionUs); + } + + @Override + public boolean isLoading() { + return mediaPeriod.isLoading(); + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + Assertions.checkNotNull(callback).onPrepared(this); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + /* package */ boolean isPendingInitialDiscontinuity() { + return pendingInitialDiscontinuityPositionUs != C.TIME_UNSET; + } + + private SeekParameters clipSeekParameters(long positionUs, SeekParameters seekParameters) { + long toleranceBeforeUs = + Util.constrainValue( + seekParameters.toleranceBeforeUs, /* min= */ 0, /* max= */ positionUs - startUs); + long toleranceAfterUs = + Util.constrainValue( + seekParameters.toleranceAfterUs, + /* min= */ 0, + /* max= */ endUs == C.TIME_END_OF_SOURCE ? Long.MAX_VALUE : endUs - positionUs); + if (toleranceBeforeUs == seekParameters.toleranceBeforeUs + && toleranceAfterUs == seekParameters.toleranceAfterUs) { + return seekParameters; + } else { + return new SeekParameters(toleranceBeforeUs, toleranceAfterUs); + } + } + + private static boolean shouldKeepInitialDiscontinuity( + long startUs, @NullableType TrackSelection[] selections) { + // If the clipping start position is non-zero, the clipping sample streams will adjust + // timestamps on buffers they read from the unclipped sample streams. These adjusted buffer + // timestamps can be negative, because sample streams provide buffers starting at a key-frame, + // which may be before the clipping start point. When the renderer reads a buffer with a + // negative timestamp, its offset timestamp can jump backwards compared to the last timestamp + // read in the previous period. Renderer implementations may not allow this, so we signal a + // discontinuity which resets the renderers before they read the clipping sample stream. + // However, for audio-only track selections we assume to have random access seek behaviour and + // do not need an initial discontinuity to reset the renderer. + if (startUs != 0) { + for (TrackSelection trackSelection : selections) { + if (trackSelection != null) { + Format selectedFormat = trackSelection.getSelectedFormat(); + if (!MimeTypes.isAudio(selectedFormat.sampleMimeType)) { + return true; + } + } + } + } + return false; + } + + /** + * Wraps a {@link SampleStream} and clips its samples. + */ + private final class ClippingSampleStream implements SampleStream { + + public final SampleStream childStream; + + private boolean sentEos; + + public ClippingSampleStream(SampleStream childStream) { + this.childStream = childStream; + } + + public void clearSentEos() { + sentEos = false; + } + + @Override + public boolean isReady() { + return !isPendingInitialDiscontinuity() && childStream.isReady(); + } + + @Override + public void maybeThrowError() throws IOException { + childStream.maybeThrowError(); + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + if (isPendingInitialDiscontinuity()) { + return C.RESULT_NOTHING_READ; + } + if (sentEos) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + int result = childStream.readData(formatHolder, buffer, requireFormat); + if (result == C.RESULT_FORMAT_READ) { + Format format = Assertions.checkNotNull(formatHolder.format); + if (format.encoderDelay != 0 || format.encoderPadding != 0) { + // Clear gapless playback metadata if the start/end points don't match the media. + int encoderDelay = startUs != 0 ? 0 : format.encoderDelay; + int encoderPadding = endUs != C.TIME_END_OF_SOURCE ? 0 : format.encoderPadding; + formatHolder.format = format.copyWithGaplessInfo(encoderDelay, encoderPadding); + } + return C.RESULT_FORMAT_READ; + } + if (endUs != C.TIME_END_OF_SOURCE + && ((result == C.RESULT_BUFFER_READ && buffer.timeUs >= endUs) + || (result == C.RESULT_NOTHING_READ + && getBufferedPositionUs() == C.TIME_END_OF_SOURCE + && !buffer.waitingForKeys))) { + buffer.clear(); + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + sentEos = true; + return C.RESULT_BUFFER_READ; + } + return result; + } + + @Override + public int skipData(long positionUs) { + if (isPendingInitialDiscontinuity()) { + return C.RESULT_NOTHING_READ; + } + return childStream.skipData(positionUs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java new file mode 100644 index 0000000000..373076957d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +/** + * {@link MediaSource} that wraps a source and clips its timeline based on specified start/end + * positions. The wrapped source must consist of a single period. + */ +public final class ClippingMediaSource extends CompositeMediaSource<Void> { + + /** Thrown when a {@link ClippingMediaSource} cannot clip its wrapped source. */ + public static final class IllegalClippingException extends IOException { + + /** + * The reason clipping failed. One of {@link #REASON_INVALID_PERIOD_COUNT}, {@link + * #REASON_NOT_SEEKABLE_TO_START} or {@link #REASON_START_EXCEEDS_END}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_INVALID_PERIOD_COUNT, REASON_NOT_SEEKABLE_TO_START, REASON_START_EXCEEDS_END}) + public @interface Reason {} + /** The wrapped source doesn't consist of a single period. */ + public static final int REASON_INVALID_PERIOD_COUNT = 0; + /** The wrapped source is not seekable and a non-zero clipping start position was specified. */ + public static final int REASON_NOT_SEEKABLE_TO_START = 1; + /** The wrapped source ends before the specified clipping start position. */ + public static final int REASON_START_EXCEEDS_END = 2; + + /** The reason clipping failed. */ + public final @Reason int reason; + + /** + * @param reason The reason clipping failed. + */ + public IllegalClippingException(@Reason int reason) { + super("Illegal clipping: " + getReasonDescription(reason)); + this.reason = reason; + } + + private static String getReasonDescription(@Reason int reason) { + switch (reason) { + case REASON_INVALID_PERIOD_COUNT: + return "invalid period count"; + case REASON_NOT_SEEKABLE_TO_START: + return "not seekable to start"; + case REASON_START_EXCEEDS_END: + return "start exceeds end"; + default: + return "unknown"; + } + } + } + + private final MediaSource mediaSource; + private final long startUs; + private final long endUs; + private final boolean enableInitialDiscontinuity; + private final boolean allowDynamicClippingUpdates; + private final boolean relativeToDefaultPosition; + private final ArrayList<ClippingMediaPeriod> mediaPeriods; + private final Timeline.Window window; + + @Nullable private ClippingTimeline clippingTimeline; + @Nullable private IllegalClippingException clippingError; + private long periodStartUs; + private long periodEndUs; + + /** + * Creates a new clipping source that wraps the specified source and provides samples between the + * specified start and end position. + * + * @param mediaSource The single-period source to wrap. + * @param startPositionUs The start position within {@code mediaSource}'s window at which to start + * providing samples, in microseconds. + * @param endPositionUs The end position within {@code mediaSource}'s window at which to stop + * providing samples, in microseconds. Specify {@link C#TIME_END_OF_SOURCE} to provide samples + * from the specified start point up to the end of the source. Specifying a position that + * exceeds the {@code mediaSource}'s duration will also result in the end of the source not + * being clipped. + */ + public ClippingMediaSource(MediaSource mediaSource, long startPositionUs, long endPositionUs) { + this( + mediaSource, + startPositionUs, + endPositionUs, + /* enableInitialDiscontinuity= */ true, + /* allowDynamicClippingUpdates= */ false, + /* relativeToDefaultPosition= */ false); + } + + /** + * Creates a new clipping source that wraps the specified source and provides samples from the + * default position for the specified duration. + * + * @param mediaSource The single-period source to wrap. + * @param durationUs The duration from the default position in the window in {@code mediaSource}'s + * timeline at which to stop providing samples. Specifying a duration that exceeds the {@code + * mediaSource}'s duration will result in the end of the source not being clipped. + */ + public ClippingMediaSource(MediaSource mediaSource, long durationUs) { + this( + mediaSource, + /* startPositionUs= */ 0, + /* endPositionUs= */ durationUs, + /* enableInitialDiscontinuity= */ true, + /* allowDynamicClippingUpdates= */ false, + /* relativeToDefaultPosition= */ true); + } + + /** + * Creates a new clipping source that wraps the specified source. + * + * <p>If the start point is guaranteed to be a key frame, pass {@code false} to {@code + * enableInitialPositionDiscontinuity} to suppress an initial discontinuity when a period is first + * read from. + * + * <p>For live streams, if the clipping positions should move with the live window, pass {@code + * true} to {@code allowDynamicClippingUpdates}. Otherwise, the live stream ends when the playback + * reaches {@code endPositionUs} in the last reported live window at the time a media period was + * created. + * + * @param mediaSource The single-period source to wrap. + * @param startPositionUs The start position at which to start providing samples, in microseconds. + * If {@code relativeToDefaultPosition} is {@code false}, this position is relative to the + * start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition} + * is {@code true}, this position is relative to the default position in the window in {@code + * mediaSource}'s timeline. + * @param endPositionUs The end position at which to stop providing samples, in microseconds. + * Specify {@link C#TIME_END_OF_SOURCE} to provide samples from the specified start point up + * to the end of the source. Specifying a position that exceeds the {@code mediaSource}'s + * duration will also result in the end of the source not being clipped. If {@code + * relativeToDefaultPosition} is {@code false}, the specified position is relative to the + * start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition} + * is {@code true}, this position is relative to the default position in the window in {@code + * mediaSource}'s timeline. + * @param enableInitialDiscontinuity Whether the initial discontinuity should be enabled. + * @param allowDynamicClippingUpdates Whether the clipping of active media periods moves with a + * live window. If {@code false}, playback ends when it reaches {@code endPositionUs} in the + * last reported live window at the time a media period was created. + * @param relativeToDefaultPosition Whether {@code startPositionUs} and {@code endPositionUs} are + * relative to the default position in the window in {@code mediaSource}'s timeline. + */ + public ClippingMediaSource( + MediaSource mediaSource, + long startPositionUs, + long endPositionUs, + boolean enableInitialDiscontinuity, + boolean allowDynamicClippingUpdates, + boolean relativeToDefaultPosition) { + Assertions.checkArgument(startPositionUs >= 0); + this.mediaSource = Assertions.checkNotNull(mediaSource); + startUs = startPositionUs; + endUs = endPositionUs; + this.enableInitialDiscontinuity = enableInitialDiscontinuity; + this.allowDynamicClippingUpdates = allowDynamicClippingUpdates; + this.relativeToDefaultPosition = relativeToDefaultPosition; + mediaPeriods = new ArrayList<>(); + window = new Timeline.Window(); + } + + @Override + @Nullable + public Object getTag() { + return mediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, mediaSource); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (clippingError != null) { + throw clippingError; + } + super.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + ClippingMediaPeriod mediaPeriod = + new ClippingMediaPeriod( + mediaSource.createPeriod(id, allocator, startPositionUs), + enableInitialDiscontinuity, + periodStartUs, + periodEndUs); + mediaPeriods.add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + Assertions.checkState(mediaPeriods.remove(mediaPeriod)); + mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); + if (mediaPeriods.isEmpty() && !allowDynamicClippingUpdates) { + refreshClippedTimeline(Assertions.checkNotNull(clippingTimeline).timeline); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + clippingError = null; + clippingTimeline = null; + } + + @Override + protected void onChildSourceInfoRefreshed(Void id, MediaSource mediaSource, Timeline timeline) { + if (clippingError != null) { + return; + } + refreshClippedTimeline(timeline); + } + + private void refreshClippedTimeline(Timeline timeline) { + long windowStartUs; + long windowEndUs; + timeline.getWindow(/* windowIndex= */ 0, window); + long windowPositionInPeriodUs = window.getPositionInFirstPeriodUs(); + if (clippingTimeline == null || mediaPeriods.isEmpty() || allowDynamicClippingUpdates) { + windowStartUs = startUs; + windowEndUs = endUs; + if (relativeToDefaultPosition) { + long windowDefaultPositionUs = window.getDefaultPositionUs(); + windowStartUs += windowDefaultPositionUs; + windowEndUs += windowDefaultPositionUs; + } + periodStartUs = windowPositionInPeriodUs + windowStartUs; + periodEndUs = + endUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : windowPositionInPeriodUs + windowEndUs; + int count = mediaPeriods.size(); + for (int i = 0; i < count; i++) { + mediaPeriods.get(i).updateClipping(periodStartUs, periodEndUs); + } + } else { + // Keep window fixed at previous period position. + windowStartUs = periodStartUs - windowPositionInPeriodUs; + windowEndUs = + endUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : periodEndUs - windowPositionInPeriodUs; + } + try { + clippingTimeline = new ClippingTimeline(timeline, windowStartUs, windowEndUs); + } catch (IllegalClippingException e) { + clippingError = e; + return; + } + refreshSourceInfo(clippingTimeline); + } + + @Override + protected long getMediaTimeForChildMediaTime(Void id, long mediaTimeMs) { + if (mediaTimeMs == C.TIME_UNSET) { + return C.TIME_UNSET; + } + long startMs = C.usToMs(startUs); + long clippedTimeMs = Math.max(0, mediaTimeMs - startMs); + if (endUs != C.TIME_END_OF_SOURCE) { + clippedTimeMs = Math.min(C.usToMs(endUs) - startMs, clippedTimeMs); + } + return clippedTimeMs; + } + + /** + * Provides a clipped view of a specified timeline. + */ + private static final class ClippingTimeline extends ForwardingTimeline { + + private final long startUs; + private final long endUs; + private final long durationUs; + private final boolean isDynamic; + + /** + * Creates a new clipping timeline that wraps the specified timeline. + * + * @param timeline The timeline to clip. + * @param startUs The number of microseconds to clip from the start of {@code timeline}. + * @param endUs The end position in microseconds for the clipped timeline relative to the start + * of {@code timeline}, or {@link C#TIME_END_OF_SOURCE} to clip no samples from the end. + * @throws IllegalClippingException If the timeline could not be clipped. + */ + public ClippingTimeline(Timeline timeline, long startUs, long endUs) + throws IllegalClippingException { + super(timeline); + if (timeline.getPeriodCount() != 1) { + throw new IllegalClippingException(IllegalClippingException.REASON_INVALID_PERIOD_COUNT); + } + Window window = timeline.getWindow(0, new Window()); + startUs = Math.max(0, startUs); + long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : Math.max(0, endUs); + if (window.durationUs != C.TIME_UNSET) { + if (resolvedEndUs > window.durationUs) { + resolvedEndUs = window.durationUs; + } + if (startUs != 0 && !window.isSeekable) { + throw new IllegalClippingException(IllegalClippingException.REASON_NOT_SEEKABLE_TO_START); + } + if (startUs > resolvedEndUs) { + throw new IllegalClippingException(IllegalClippingException.REASON_START_EXCEEDS_END); + } + } + this.startUs = startUs; + this.endUs = resolvedEndUs; + durationUs = resolvedEndUs == C.TIME_UNSET ? C.TIME_UNSET : (resolvedEndUs - startUs); + isDynamic = + window.isDynamic + && (resolvedEndUs == C.TIME_UNSET + || (window.durationUs != C.TIME_UNSET && resolvedEndUs == window.durationUs)); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + timeline.getWindow(/* windowIndex= */ 0, window, /* defaultPositionProjectionUs= */ 0); + window.positionInFirstPeriodUs += startUs; + window.durationUs = durationUs; + window.isDynamic = isDynamic; + if (window.defaultPositionUs != C.TIME_UNSET) { + window.defaultPositionUs = Math.max(window.defaultPositionUs, startUs); + window.defaultPositionUs = endUs == C.TIME_UNSET ? window.defaultPositionUs + : Math.min(window.defaultPositionUs, endUs); + window.defaultPositionUs -= startUs; + } + long startMs = C.usToMs(startUs); + if (window.presentationStartTimeMs != C.TIME_UNSET) { + window.presentationStartTimeMs += startMs; + } + if (window.windowStartTimeMs != C.TIME_UNSET) { + window.windowStartTimeMs += startMs; + } + return window; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(/* periodIndex= */ 0, period, setIds); + long positionInClippedWindowUs = period.getPositionInWindowUs() - startUs; + long periodDurationUs = + durationUs == C.TIME_UNSET ? C.TIME_UNSET : durationUs - positionInClippedWindowUs; + return period.set( + period.id, period.uid, /* windowIndex= */ 0, periodDurationUs, positionInClippedWindowUs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java new file mode 100644 index 0000000000..ed46b8ee94 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -0,0 +1,354 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.HashMap; + +/** + * Composite {@link MediaSource} consisting of multiple child sources. + * + * @param <T> The type of the id used to identify prepared child sources. + */ +public abstract class CompositeMediaSource<T> extends BaseMediaSource { + + private final HashMap<T, MediaSourceAndListener> childSources; + + @Nullable private Handler eventHandler; + @Nullable private TransferListener mediaTransferListener; + + /** Creates composite media source without child sources. */ + protected CompositeMediaSource() { + childSources = new HashMap<>(); + } + + @Override + @CallSuper + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + this.mediaTransferListener = mediaTransferListener; + eventHandler = new Handler(); + } + + @Override + @CallSuper + public void maybeThrowSourceInfoRefreshError() throws IOException { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + @Override + @CallSuper + protected void enableInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.enable(childSource.caller); + } + } + + @Override + @CallSuper + protected void disableInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.disable(childSource.caller); + } + } + + @Override + @CallSuper + protected void releaseSourceInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.releaseSource(childSource.caller); + childSource.mediaSource.removeEventListener(childSource.eventListener); + } + childSources.clear(); + } + + /** + * Called when the source info of a child source has been refreshed. + * + * @param id The unique id used to prepare the child source. + * @param mediaSource The child source whose source info has been refreshed. + * @param timeline The timeline of the child source. + */ + protected abstract void onChildSourceInfoRefreshed( + T id, MediaSource mediaSource, Timeline timeline); + + /** + * Prepares a child source. + * + * <p>{@link #onChildSourceInfoRefreshed(Object, MediaSource, Timeline)} will be called when the + * child source updates its timeline with the same {@code id} passed to this method. + * + * <p>Any child sources that aren't explicitly released with {@link #releaseChildSource(Object)} + * will be released in {@link #releaseSourceInternal()}. + * + * @param id A unique id to identify the child source preparation. Null is allowed as an id. + * @param mediaSource The child {@link MediaSource}. + */ + protected final void prepareChildSource(final T id, MediaSource mediaSource) { + Assertions.checkArgument(!childSources.containsKey(id)); + MediaSourceCaller caller = + (source, timeline) -> onChildSourceInfoRefreshed(id, source, timeline); + MediaSourceEventListener eventListener = new ForwardingEventListener(id); + childSources.put(id, new MediaSourceAndListener(mediaSource, caller, eventListener)); + mediaSource.addEventListener(Assertions.checkNotNull(eventHandler), eventListener); + mediaSource.prepareSource(caller, mediaTransferListener); + if (!isEnabled()) { + mediaSource.disable(caller); + } + } + + /** + * Enables a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void enableChildSource(final T id) { + MediaSourceAndListener enabledChild = Assertions.checkNotNull(childSources.get(id)); + enabledChild.mediaSource.enable(enabledChild.caller); + } + + /** + * Disables a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void disableChildSource(final T id) { + MediaSourceAndListener disabledChild = Assertions.checkNotNull(childSources.get(id)); + disabledChild.mediaSource.disable(disabledChild.caller); + } + + /** + * Releases a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void releaseChildSource(T id) { + MediaSourceAndListener removedChild = Assertions.checkNotNull(childSources.remove(id)); + removedChild.mediaSource.releaseSource(removedChild.caller); + removedChild.mediaSource.removeEventListener(removedChild.eventListener); + } + + /** + * Returns the window index in the composite source corresponding to the specified window index in + * a child source. The default implementation does not change the window index. + * + * @param id The unique id used to prepare the child source. + * @param windowIndex A window index of the child source. + * @return The corresponding window index in the composite source. + */ + protected int getWindowIndexForChildWindowIndex(T id, int windowIndex) { + return windowIndex; + } + + /** + * Returns the {@link MediaPeriodId} in the composite source corresponding to the specified {@link + * MediaPeriodId} in a child source. The default implementation does not change the media period + * id. + * + * @param id The unique id used to prepare the child source. + * @param mediaPeriodId A {@link MediaPeriodId} of the child source. + * @return The corresponding {@link MediaPeriodId} in the composite source. Null if no + * corresponding media period id can be determined. + */ + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + T id, MediaPeriodId mediaPeriodId) { + return mediaPeriodId; + } + + /** + * Returns the media time in the composite source corresponding to the specified media time in a + * child source. The default implementation does not change the media time. + * + * @param id The unique id used to prepare the child source. + * @param mediaTimeMs A media time of the child source, in milliseconds. + * @return The corresponding media time in the composite source, in milliseconds. + */ + protected long getMediaTimeForChildMediaTime(@Nullable T id, long mediaTimeMs) { + return mediaTimeMs; + } + + /** + * Returns whether {@link MediaSourceEventListener#onMediaPeriodCreated(int, MediaPeriodId)} and + * {@link MediaSourceEventListener#onMediaPeriodReleased(int, MediaPeriodId)} events of the given + * media period should be reported. The default implementation is to always report these events. + * + * @param mediaPeriodId A {@link MediaPeriodId} in the composite media source. + * @return Whether create and release events for this media period should be reported. + */ + protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { + return true; + } + + private static final class MediaSourceAndListener { + + public final MediaSource mediaSource; + public final MediaSourceCaller caller; + public final MediaSourceEventListener eventListener; + + public MediaSourceAndListener( + MediaSource mediaSource, MediaSourceCaller caller, MediaSourceEventListener eventListener) { + this.mediaSource = mediaSource; + this.caller = caller; + this.eventListener = eventListener; + } + } + + private final class ForwardingEventListener implements MediaSourceEventListener { + + private final T id; + private EventDispatcher eventDispatcher; + + public ForwardingEventListener(T id) { + this.eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + this.id = id; + } + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + if (shouldDispatchCreateOrReleaseEvent( + Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { + eventDispatcher.mediaPeriodCreated(); + } + } + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + if (shouldDispatchCreateOrReleaseEvent( + Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { + eventDispatcher.mediaPeriodReleased(); + } + } + } + + @Override + public void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadStarted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCompleted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCanceled(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadError( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData), error, wasCanceled); + } + } + + @Override + public void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.readingStarted(); + } + } + + @Override + public void onUpstreamDiscarded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.upstreamDiscarded(maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + /** Updates the event dispatcher and returns whether the event should be dispatched. */ + private boolean maybeUpdateEventDispatcher( + int childWindowIndex, @Nullable MediaPeriodId childMediaPeriodId) { + MediaPeriodId mediaPeriodId = null; + if (childMediaPeriodId != null) { + mediaPeriodId = getMediaPeriodIdForChildMediaPeriodId(id, childMediaPeriodId); + if (mediaPeriodId == null) { + // Media period not found. Ignore event. + return false; + } + } + int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); + if (eventDispatcher.windowIndex != windowIndex + || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) { + eventDispatcher = + createEventDispatcher(windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0); + } + return true; + } + + private MediaLoadData maybeUpdateMediaLoadData(MediaLoadData mediaLoadData) { + long mediaStartTimeMs = getMediaTimeForChildMediaTime(id, mediaLoadData.mediaStartTimeMs); + long mediaEndTimeMs = getMediaTimeForChildMediaTime(id, mediaLoadData.mediaEndTimeMs); + if (mediaStartTimeMs == mediaLoadData.mediaStartTimeMs + && mediaEndTimeMs == mediaLoadData.mediaEndTimeMs) { + return mediaLoadData; + } + return new MediaLoadData( + mediaLoadData.dataType, + mediaLoadData.trackType, + mediaLoadData.trackFormat, + mediaLoadData.trackSelectionReason, + mediaLoadData.trackSelectionData, + mediaStartTimeMs, + mediaEndTimeMs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java new file mode 100644 index 0000000000..9a72903528 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * A {@link SequenceableLoader} that encapsulates multiple other {@link SequenceableLoader}s. + */ +public class CompositeSequenceableLoader implements SequenceableLoader { + + protected final SequenceableLoader[] loaders; + + public CompositeSequenceableLoader(SequenceableLoader[] loaders) { + this.loaders = loaders; + } + + @Override + public final long getBufferedPositionUs() { + long bufferedPositionUs = Long.MAX_VALUE; + for (SequenceableLoader loader : loaders) { + long loaderBufferedPositionUs = loader.getBufferedPositionUs(); + if (loaderBufferedPositionUs != C.TIME_END_OF_SOURCE) { + bufferedPositionUs = Math.min(bufferedPositionUs, loaderBufferedPositionUs); + } + } + return bufferedPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : bufferedPositionUs; + } + + @Override + public final long getNextLoadPositionUs() { + long nextLoadPositionUs = Long.MAX_VALUE; + for (SequenceableLoader loader : loaders) { + long loaderNextLoadPositionUs = loader.getNextLoadPositionUs(); + if (loaderNextLoadPositionUs != C.TIME_END_OF_SOURCE) { + nextLoadPositionUs = Math.min(nextLoadPositionUs, loaderNextLoadPositionUs); + } + } + return nextLoadPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : nextLoadPositionUs; + } + + @Override + public final void reevaluateBuffer(long positionUs) { + for (SequenceableLoader loader : loaders) { + loader.reevaluateBuffer(positionUs); + } + } + + @Override + public boolean continueLoading(long positionUs) { + boolean madeProgress = false; + boolean madeProgressThisIteration; + do { + madeProgressThisIteration = false; + long nextLoadPositionUs = getNextLoadPositionUs(); + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE) { + break; + } + for (SequenceableLoader loader : loaders) { + long loaderNextLoadPositionUs = loader.getNextLoadPositionUs(); + boolean isLoaderBehind = + loaderNextLoadPositionUs != C.TIME_END_OF_SOURCE + && loaderNextLoadPositionUs <= positionUs; + if (loaderNextLoadPositionUs == nextLoadPositionUs || isLoaderBehind) { + madeProgressThisIteration |= loader.continueLoading(positionUs); + } + } + madeProgress |= madeProgressThisIteration; + } while (madeProgressThisIteration); + return madeProgress; + } + + @Override + public boolean isLoading() { + for (SequenceableLoader loader : loaders) { + if (loader.isLoading()) { + return true; + } + } + return false; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java new file mode 100644 index 0000000000..1ac76d6167 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * A factory to create composite {@link SequenceableLoader}s. + */ +public interface CompositeSequenceableLoaderFactory { + + /** + * Creates a composite {@link SequenceableLoader}. + * + * @param loaders The sub-loaders that make up the {@link SequenceableLoader} to be built. + * @return A composite {@link SequenceableLoader} that comprises the given loaders. + */ + SequenceableLoader createCompositeSequenceableLoader(SequenceableLoader... loaders); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java new file mode 100644 index 0000000000..aa6f486473 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -0,0 +1,1017 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import android.os.Message; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified + * during playback. It is valid for the same {@link MediaSource} instance to be present more than + * once in the concatenation. Access to this class is thread-safe. + */ +public final class ConcatenatingMediaSource extends CompositeMediaSource<MediaSourceHolder> { + + private static final int MSG_ADD = 0; + private static final int MSG_REMOVE = 1; + private static final int MSG_MOVE = 2; + private static final int MSG_SET_SHUFFLE_ORDER = 3; + private static final int MSG_UPDATE_TIMELINE = 4; + private static final int MSG_ON_COMPLETION = 5; + + // Accessed on any thread. + @GuardedBy("this") + private final List<MediaSourceHolder> mediaSourcesPublic; + + @GuardedBy("this") + private final Set<HandlerAndRunnable> pendingOnCompletionActions; + + @GuardedBy("this") + @Nullable + private Handler playbackThreadHandler; + + // Accessed on the playback thread only. + private final List<MediaSourceHolder> mediaSourceHolders; + private final Map<MediaPeriod, MediaSourceHolder> mediaSourceByMediaPeriod; + private final Map<Object, MediaSourceHolder> mediaSourceByUid; + private final Set<MediaSourceHolder> enabledMediaSourceHolders; + private final boolean isAtomic; + private final boolean useLazyPreparation; + + private boolean timelineUpdateScheduled; + private Set<HandlerAndRunnable> nextTimelineUpdateOnCompletionActions; + private ShuffleOrder shuffleOrder; + + /** + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same + * {@link MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource(MediaSource... mediaSources) { + this(/* isAtomic= */ false, mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource(boolean isAtomic, MediaSource... mediaSources) { + this(isAtomic, new DefaultShuffleOrder(0), mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource( + boolean isAtomic, ShuffleOrder shuffleOrder, MediaSource... mediaSources) { + this(isAtomic, /* useLazyPreparation= */ false, shuffleOrder, mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest + * loads and other initial preparation steps happen immediately. If true, these initial + * preparations are triggered only when the player starts buffering the media. + * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + @SuppressWarnings("initialization") + public ConcatenatingMediaSource( + boolean isAtomic, + boolean useLazyPreparation, + ShuffleOrder shuffleOrder, + MediaSource... mediaSources) { + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); + } + this.shuffleOrder = shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; + this.mediaSourceByMediaPeriod = new IdentityHashMap<>(); + this.mediaSourceByUid = new HashMap<>(); + this.mediaSourcesPublic = new ArrayList<>(); + this.mediaSourceHolders = new ArrayList<>(); + this.nextTimelineUpdateOnCompletionActions = new HashSet<>(); + this.pendingOnCompletionActions = new HashSet<>(); + this.enabledMediaSourceHolders = new HashSet<>(); + this.isAtomic = isAtomic; + this.useLazyPreparation = useLazyPreparation; + addMediaSources(Arrays.asList(mediaSources)); + } + + /** + * Appends a {@link MediaSource} to the playlist. + * + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(MediaSource mediaSource) { + addMediaSource(mediaSourcesPublic.size(), mediaSource); + } + + /** + * Appends a {@link MediaSource} to the playlist and executes a custom action on completion. + * + * @param mediaSource The {@link MediaSource} to be added to the list. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been added to the playlist. + */ + public synchronized void addMediaSource( + MediaSource mediaSource, Handler handler, Runnable onCompletionAction) { + addMediaSource(mediaSourcesPublic.size(), mediaSource, handler, onCompletionAction); + } + + /** + * Adds a {@link MediaSource} to the playlist. + * + * @param index The index at which the new {@link MediaSource} will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(int index, MediaSource mediaSource) { + addPublicMediaSources( + index, + Collections.singletonList(mediaSource), + /* handler= */ null, + /* onCompletionAction= */ null); + } + + /** + * Adds a {@link MediaSource} to the playlist and executes a custom action on completion. + * + * @param index The index at which the new {@link MediaSource} will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSource The {@link MediaSource} to be added to the list. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been added to the playlist. + */ + public synchronized void addMediaSource( + int index, MediaSource mediaSource, Handler handler, Runnable onCompletionAction) { + addPublicMediaSources( + index, Collections.singletonList(mediaSource), handler, onCompletionAction); + } + + /** + * Appends multiple {@link MediaSource}s to the playlist. + * + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(Collection<MediaSource> mediaSources) { + addPublicMediaSources( + mediaSourcesPublic.size(), + mediaSources, + /* handler= */ null, + /* onCompletionAction= */ null); + } + + /** + * Appends multiple {@link MediaSource}s to the playlist and executes a custom action on + * completion. + * + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * sources have been added to the playlist. + */ + public synchronized void addMediaSources( + Collection<MediaSource> mediaSources, Handler handler, Runnable onCompletionAction) { + addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, onCompletionAction); + } + + /** + * Adds multiple {@link MediaSource}s to the playlist. + * + * @param index The index at which the new {@link MediaSource}s will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(int index, Collection<MediaSource> mediaSources) { + addPublicMediaSources(index, mediaSources, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Adds multiple {@link MediaSource}s to the playlist and executes a custom action on completion. + * + * @param index The index at which the new {@link MediaSource}s will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * sources have been added to the playlist. + */ + public synchronized void addMediaSources( + int index, + Collection<MediaSource> mediaSources, + Handler handler, + Runnable onCompletionAction) { + addPublicMediaSources(index, mediaSources, handler, onCompletionAction); + } + + /** + * Removes a {@link MediaSource} from the playlist. + * + * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int, + * int)} instead. + * + * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link + * #removeMediaSourceRange(int, int)} instead. + * + * @param index The index at which the media source will be removed. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @return The removed {@link MediaSource}. + */ + public synchronized MediaSource removeMediaSource(int index) { + MediaSource removedMediaSource = getMediaSource(index); + removePublicMediaSources(index, index + 1, /* handler= */ null, /* onCompletionAction= */ null); + return removedMediaSource; + } + + /** + * Removes a {@link MediaSource} from the playlist and executes a custom action on completion. + * + * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int, + * int, Handler, Runnable)} instead. + * + * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link + * #removeMediaSourceRange(int, int, Handler, Runnable)} instead. + * + * @param index The index at which the media source will be removed. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been removed from the playlist. + * @return The removed {@link MediaSource}. + */ + public synchronized MediaSource removeMediaSource( + int index, Handler handler, Runnable onCompletionAction) { + MediaSource removedMediaSource = getMediaSource(index); + removePublicMediaSources(index, index + 1, handler, onCompletionAction); + return removedMediaSource; + } + + /** + * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index + * (included) and a final index (excluded). + * + * <p>Note: when specified range is empty, no actual media source is removed and no exception is + * thrown. + * + * @param fromIndex The initial range index, pointing to the first media source that will be + * removed. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @throws IndexOutOfBoundsException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} + */ + public synchronized void removeMediaSourceRange(int fromIndex, int toIndex) { + removePublicMediaSources( + fromIndex, toIndex, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index + * (included) and a final index (excluded), and executes a custom action on completion. + * + * <p>Note: when specified range is empty, no actual media source is removed and no exception is + * thrown. + * + * @param fromIndex The initial range index, pointing to the first media source that will be + * removed. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source range has been removed from the playlist. + * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} + */ + public synchronized void removeMediaSourceRange( + int fromIndex, int toIndex, Handler handler, Runnable onCompletionAction) { + removePublicMediaSources(fromIndex, toIndex, handler, onCompletionAction); + } + + /** + * Moves an existing {@link MediaSource} within the playlist. + * + * @param currentIndex The current index of the media source in the playlist. This index must be + * in the range of 0 <= index < {@link #getSize()}. + * @param newIndex The target index of the media source in the playlist. This index must be in the + * range of 0 <= index < {@link #getSize()}. + */ + public synchronized void moveMediaSource(int currentIndex, int newIndex) { + movePublicMediaSource( + currentIndex, newIndex, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Moves an existing {@link MediaSource} within the playlist and executes a custom action on + * completion. + * + * @param currentIndex The current index of the media source in the playlist. This index must be + * in the range of 0 <= index < {@link #getSize()}. + * @param newIndex The target index of the media source in the playlist. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been moved. + */ + public synchronized void moveMediaSource( + int currentIndex, int newIndex, Handler handler, Runnable onCompletionAction) { + movePublicMediaSource(currentIndex, newIndex, handler, onCompletionAction); + } + + /** Clears the playlist. */ + public synchronized void clear() { + removeMediaSourceRange(0, getSize()); + } + + /** + * Clears the playlist and executes a custom action on completion. + * + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the playlist + * has been cleared. + */ + public synchronized void clear(Handler handler, Runnable onCompletionAction) { + removeMediaSourceRange(0, getSize(), handler, onCompletionAction); + } + + /** Returns the number of media sources in the playlist. */ + public synchronized int getSize() { + return mediaSourcesPublic.size(); + } + + /** + * Returns the {@link MediaSource} at a specified index. + * + * @param index An index in the range of 0 <= index <= {@link #getSize()}. + * @return The {@link MediaSource} at this index. + */ + public synchronized MediaSource getMediaSource(int index) { + return mediaSourcesPublic.get(index).mediaSource; + } + + /** + * Sets a new shuffle order to use when shuffling the child media sources. + * + * @param shuffleOrder A {@link ShuffleOrder}. + */ + public synchronized void setShuffleOrder(ShuffleOrder shuffleOrder) { + setPublicShuffleOrder(shuffleOrder, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Sets a new shuffle order to use when shuffling the child media sources. + * + * @param shuffleOrder A {@link ShuffleOrder}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the shuffle + * order has been changed. + */ + public synchronized void setShuffleOrder( + ShuffleOrder shuffleOrder, Handler handler, Runnable onCompletionAction) { + setPublicShuffleOrder(shuffleOrder, handler, onCompletionAction); + } + + // CompositeMediaSource implementation. + + @Override + @Nullable + public Object getTag() { + return null; + } + + @Override + protected synchronized void prepareSourceInternal( + @Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + playbackThreadHandler = new Handler(/* callback= */ this::handleMessage); + if (mediaSourcesPublic.isEmpty()) { + updateTimelineAndScheduleOnCompletionActions(); + } else { + shuffleOrder = shuffleOrder.cloneAndInsert(0, mediaSourcesPublic.size()); + addMediaSourcesInternal(0, mediaSourcesPublic); + scheduleTimelineUpdate(); + } + } + + @SuppressWarnings("MissingSuperCall") + @Override + protected void enableInternal() { + // Suppress enabling all child sources here as they can be lazily enabled when creating periods. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid); + MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)); + MediaSourceHolder holder = mediaSourceByUid.get(mediaSourceHolderUid); + if (holder == null) { + // Stale event. The media source has already been removed. + holder = new MediaSourceHolder(new DummyMediaSource(), useLazyPreparation); + holder.isRemoved = true; + prepareChildSource(holder, holder.mediaSource); + } + enableMediaSource(holder); + holder.activeMediaPeriodIds.add(childMediaPeriodId); + MediaPeriod mediaPeriod = + holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaSourceByMediaPeriod.put(mediaPeriod, holder); + disableUnusedMediaSources(); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MediaSourceHolder holder = + Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + holder.mediaSource.releasePeriod(mediaPeriod); + holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id); + if (!mediaSourceByMediaPeriod.isEmpty()) { + disableUnusedMediaSources(); + } + maybeReleaseChildSource(holder); + } + + @Override + protected void disableInternal() { + super.disableInternal(); + enabledMediaSourceHolders.clear(); + } + + @Override + protected synchronized void releaseSourceInternal() { + super.releaseSourceInternal(); + mediaSourceHolders.clear(); + enabledMediaSourceHolders.clear(); + mediaSourceByUid.clear(); + shuffleOrder = shuffleOrder.cloneAndClear(); + if (playbackThreadHandler != null) { + playbackThreadHandler.removeCallbacksAndMessages(null); + playbackThreadHandler = null; + } + timelineUpdateScheduled = false; + nextTimelineUpdateOnCompletionActions.clear(); + dispatchOnCompletionActions(pendingOnCompletionActions); + } + + @Override + protected void onChildSourceInfoRefreshed( + MediaSourceHolder mediaSourceHolder, MediaSource mediaSource, Timeline timeline) { + updateMediaSourceInternal(mediaSourceHolder, timeline); + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + MediaSourceHolder mediaSourceHolder, MediaPeriodId mediaPeriodId) { + for (int i = 0; i < mediaSourceHolder.activeMediaPeriodIds.size(); i++) { + // Ensure the reported media period id has the same window sequence number as the one created + // by this media source. Otherwise it does not belong to this child source. + if (mediaSourceHolder.activeMediaPeriodIds.get(i).windowSequenceNumber + == mediaPeriodId.windowSequenceNumber) { + Object periodUid = getPeriodUid(mediaSourceHolder, mediaPeriodId.periodUid); + return mediaPeriodId.copyWithPeriodUid(periodUid); + } + } + return null; + } + + @Override + protected int getWindowIndexForChildWindowIndex( + MediaSourceHolder mediaSourceHolder, int windowIndex) { + return windowIndex + mediaSourceHolder.firstWindowIndexInChild; + } + + // Internal methods. Called from any thread. + + @GuardedBy("this") + private void addPublicMediaSources( + int index, + Collection<MediaSource> mediaSources, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); + } + List<MediaSourceHolder> mediaSourceHolders = new ArrayList<>(mediaSources.size()); + for (MediaSource mediaSource : mediaSources) { + mediaSourceHolders.add(new MediaSourceHolder(mediaSource, useLazyPreparation)); + } + mediaSourcesPublic.addAll(index, mediaSourceHolders); + if (playbackThreadHandler != null && !mediaSources.isEmpty()) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_ADD, new MessageData<>(index, mediaSourceHolders, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void removePublicMediaSources( + int fromIndex, + int toIndex, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + Util.removeRange(mediaSourcesPublic, fromIndex, toIndex); + if (playbackThreadHandler != null) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_REMOVE, new MessageData<>(fromIndex, toIndex, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void movePublicMediaSource( + int currentIndex, + int newIndex, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + mediaSourcesPublic.add(newIndex, mediaSourcesPublic.remove(currentIndex)); + if (playbackThreadHandler != null) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_MOVE, new MessageData<>(currentIndex, newIndex, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void setPublicShuffleOrder( + ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + if (playbackThreadHandler != null) { + int size = getSize(); + if (shuffleOrder.getLength() != size) { + shuffleOrder = + shuffleOrder + .cloneAndClear() + .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size); + } + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage( + MSG_SET_SHUFFLE_ORDER, + new MessageData<>(/* index= */ 0, shuffleOrder, callbackAction)) + .sendToTarget(); + } else { + this.shuffleOrder = + shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; + if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + } + + @GuardedBy("this") + @Nullable + private HandlerAndRunnable createOnCompletionAction( + @Nullable Handler handler, @Nullable Runnable runnable) { + if (handler == null || runnable == null) { + return null; + } + HandlerAndRunnable handlerAndRunnable = new HandlerAndRunnable(handler, runnable); + pendingOnCompletionActions.add(handlerAndRunnable); + return handlerAndRunnable; + } + + // Internal methods. Called on the playback thread. + + @SuppressWarnings("unchecked") + private boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_ADD: + MessageData<Collection<MediaSourceHolder>> addMessage = + (MessageData<Collection<MediaSourceHolder>>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrder.cloneAndInsert(addMessage.index, addMessage.customData.size()); + addMediaSourcesInternal(addMessage.index, addMessage.customData); + scheduleTimelineUpdate(addMessage.onCompletionAction); + break; + case MSG_REMOVE: + MessageData<Integer> removeMessage = (MessageData<Integer>) Util.castNonNull(msg.obj); + int fromIndex = removeMessage.index; + int toIndex = removeMessage.customData; + if (fromIndex == 0 && toIndex == shuffleOrder.getLength()) { + shuffleOrder = shuffleOrder.cloneAndClear(); + } else { + shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndex); + } + for (int index = toIndex - 1; index >= fromIndex; index--) { + removeMediaSourceInternal(index); + } + scheduleTimelineUpdate(removeMessage.onCompletionAction); + break; + case MSG_MOVE: + MessageData<Integer> moveMessage = (MessageData<Integer>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrder.cloneAndRemove(moveMessage.index, moveMessage.index + 1); + shuffleOrder = shuffleOrder.cloneAndInsert(moveMessage.customData, 1); + moveMediaSourceInternal(moveMessage.index, moveMessage.customData); + scheduleTimelineUpdate(moveMessage.onCompletionAction); + break; + case MSG_SET_SHUFFLE_ORDER: + MessageData<ShuffleOrder> shuffleOrderMessage = + (MessageData<ShuffleOrder>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrderMessage.customData; + scheduleTimelineUpdate(shuffleOrderMessage.onCompletionAction); + break; + case MSG_UPDATE_TIMELINE: + updateTimelineAndScheduleOnCompletionActions(); + break; + case MSG_ON_COMPLETION: + Set<HandlerAndRunnable> actions = (Set<HandlerAndRunnable>) Util.castNonNull(msg.obj); + dispatchOnCompletionActions(actions); + break; + default: + throw new IllegalStateException(); + } + return true; + } + + private void scheduleTimelineUpdate() { + scheduleTimelineUpdate(/* onCompletionAction= */ null); + } + + private void scheduleTimelineUpdate(@Nullable HandlerAndRunnable onCompletionAction) { + if (!timelineUpdateScheduled) { + getPlaybackThreadHandlerOnPlaybackThread().obtainMessage(MSG_UPDATE_TIMELINE).sendToTarget(); + timelineUpdateScheduled = true; + } + if (onCompletionAction != null) { + nextTimelineUpdateOnCompletionActions.add(onCompletionAction); + } + } + + private void updateTimelineAndScheduleOnCompletionActions() { + timelineUpdateScheduled = false; + Set<HandlerAndRunnable> onCompletionActions = nextTimelineUpdateOnCompletionActions; + nextTimelineUpdateOnCompletionActions = new HashSet<>(); + refreshSourceInfo(new ConcatenatedTimeline(mediaSourceHolders, shuffleOrder, isAtomic)); + getPlaybackThreadHandlerOnPlaybackThread() + .obtainMessage(MSG_ON_COMPLETION, onCompletionActions) + .sendToTarget(); + } + + @SuppressWarnings("GuardedBy") + private Handler getPlaybackThreadHandlerOnPlaybackThread() { + // Write access to this value happens on the playback thread only, so playback thread reads + // don't need to be synchronized. + return Assertions.checkNotNull(playbackThreadHandler); + } + + private synchronized void dispatchOnCompletionActions( + Set<HandlerAndRunnable> onCompletionActions) { + for (HandlerAndRunnable pendingAction : onCompletionActions) { + pendingAction.dispatch(); + } + pendingOnCompletionActions.removeAll(onCompletionActions); + } + + private void addMediaSourcesInternal( + int index, Collection<MediaSourceHolder> mediaSourceHolders) { + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + addMediaSourceInternal(index++, mediaSourceHolder); + } + } + + private void addMediaSourceInternal(int newIndex, MediaSourceHolder newMediaSourceHolder) { + if (newIndex > 0) { + MediaSourceHolder previousHolder = mediaSourceHolders.get(newIndex - 1); + Timeline previousTimeline = previousHolder.mediaSource.getTimeline(); + newMediaSourceHolder.reset( + newIndex, previousHolder.firstWindowIndexInChild + previousTimeline.getWindowCount()); + } else { + newMediaSourceHolder.reset(newIndex, /* firstWindowIndexInChild= */ 0); + } + Timeline newTimeline = newMediaSourceHolder.mediaSource.getTimeline(); + correctOffsets(newIndex, /* childIndexUpdate= */ 1, newTimeline.getWindowCount()); + mediaSourceHolders.add(newIndex, newMediaSourceHolder); + mediaSourceByUid.put(newMediaSourceHolder.uid, newMediaSourceHolder); + prepareChildSource(newMediaSourceHolder, newMediaSourceHolder.mediaSource); + if (isEnabled() && mediaSourceByMediaPeriod.isEmpty()) { + enabledMediaSourceHolders.add(newMediaSourceHolder); + } else { + disableChildSource(newMediaSourceHolder); + } + } + + private void updateMediaSourceInternal(MediaSourceHolder mediaSourceHolder, Timeline timeline) { + if (mediaSourceHolder == null) { + throw new IllegalArgumentException(); + } + if (mediaSourceHolder.childIndex + 1 < mediaSourceHolders.size()) { + MediaSourceHolder nextHolder = mediaSourceHolders.get(mediaSourceHolder.childIndex + 1); + int windowOffsetUpdate = + timeline.getWindowCount() + - (nextHolder.firstWindowIndexInChild - mediaSourceHolder.firstWindowIndexInChild); + if (windowOffsetUpdate != 0) { + correctOffsets( + mediaSourceHolder.childIndex + 1, /* childIndexUpdate= */ 0, windowOffsetUpdate); + } + } + scheduleTimelineUpdate(); + } + + private void removeMediaSourceInternal(int index) { + MediaSourceHolder holder = mediaSourceHolders.remove(index); + mediaSourceByUid.remove(holder.uid); + Timeline oldTimeline = holder.mediaSource.getTimeline(); + correctOffsets(index, /* childIndexUpdate= */ -1, -oldTimeline.getWindowCount()); + holder.isRemoved = true; + maybeReleaseChildSource(holder); + } + + private void moveMediaSourceInternal(int currentIndex, int newIndex) { + int startIndex = Math.min(currentIndex, newIndex); + int endIndex = Math.max(currentIndex, newIndex); + int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild; + mediaSourceHolders.add(newIndex, mediaSourceHolders.remove(currentIndex)); + for (int i = startIndex; i <= endIndex; i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + holder.childIndex = i; + holder.firstWindowIndexInChild = windowOffset; + windowOffset += holder.mediaSource.getTimeline().getWindowCount(); + } + } + + private void correctOffsets(int startIndex, int childIndexUpdate, int windowOffsetUpdate) { + // TODO: Replace window index with uid in reporting to get rid of this inefficient method and + // the childIndex and firstWindowIndexInChild variables. + for (int i = startIndex; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + holder.childIndex += childIndexUpdate; + holder.firstWindowIndexInChild += windowOffsetUpdate; + } + } + + private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) { + // Release if the source has been removed from the playlist and no periods are still active. + if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) { + enabledMediaSourceHolders.remove(mediaSourceHolder); + releaseChildSource(mediaSourceHolder); + } + } + + private void enableMediaSource(MediaSourceHolder mediaSourceHolder) { + enabledMediaSourceHolders.add(mediaSourceHolder); + enableChildSource(mediaSourceHolder); + } + + private void disableUnusedMediaSources() { + Iterator<MediaSourceHolder> iterator = enabledMediaSourceHolders.iterator(); + while (iterator.hasNext()) { + MediaSourceHolder holder = iterator.next(); + if (holder.activeMediaPeriodIds.isEmpty()) { + disableChildSource(holder); + iterator.remove(); + } + } + } + + /** Return uid of media source holder from period uid of concatenated source. */ + private static Object getMediaSourceHolderUid(Object periodUid) { + return ConcatenatedTimeline.getChildTimelineUidFromConcatenatedUid(periodUid); + } + + /** Return uid of child period from period uid of concatenated source. */ + private static Object getChildPeriodUid(Object periodUid) { + return ConcatenatedTimeline.getChildPeriodUidFromConcatenatedUid(periodUid); + } + + private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodUid) { + return ConcatenatedTimeline.getConcatenatedUid(holder.uid, childPeriodUid); + } + + /** Data class to hold playlist media sources together with meta data needed to process them. */ + /* package */ static final class MediaSourceHolder { + + public final MaskingMediaSource mediaSource; + public final Object uid; + public final List<MediaPeriodId> activeMediaPeriodIds; + + public int childIndex; + public int firstWindowIndexInChild; + public boolean isRemoved; + + public MediaSourceHolder(MediaSource mediaSource, boolean useLazyPreparation) { + this.mediaSource = new MaskingMediaSource(mediaSource, useLazyPreparation); + this.activeMediaPeriodIds = new ArrayList<>(); + this.uid = new Object(); + } + + public void reset(int childIndex, int firstWindowIndexInChild) { + this.childIndex = childIndex; + this.firstWindowIndexInChild = firstWindowIndexInChild; + this.isRemoved = false; + this.activeMediaPeriodIds.clear(); + } + } + + /** Message used to post actions from app thread to playback thread. */ + private static final class MessageData<T> { + + public final int index; + public final T customData; + @Nullable public final HandlerAndRunnable onCompletionAction; + + public MessageData(int index, T customData, @Nullable HandlerAndRunnable onCompletionAction) { + this.index = index; + this.customData = customData; + this.onCompletionAction = onCompletionAction; + } + } + + /** Timeline exposing concatenated timelines of playlist media sources. */ + private static final class ConcatenatedTimeline extends AbstractConcatenatedTimeline { + + private final int windowCount; + private final int periodCount; + private final int[] firstPeriodInChildIndices; + private final int[] firstWindowInChildIndices; + private final Timeline[] timelines; + private final Object[] uids; + private final HashMap<Object, Integer> childIndexByUid; + + public ConcatenatedTimeline( + Collection<MediaSourceHolder> mediaSourceHolders, + ShuffleOrder shuffleOrder, + boolean isAtomic) { + super(isAtomic, shuffleOrder); + int childCount = mediaSourceHolders.size(); + firstPeriodInChildIndices = new int[childCount]; + firstWindowInChildIndices = new int[childCount]; + timelines = new Timeline[childCount]; + uids = new Object[childCount]; + childIndexByUid = new HashMap<>(); + int index = 0; + int windowCount = 0; + int periodCount = 0; + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + timelines[index] = mediaSourceHolder.mediaSource.getTimeline(); + firstWindowInChildIndices[index] = windowCount; + firstPeriodInChildIndices[index] = periodCount; + windowCount += timelines[index].getWindowCount(); + periodCount += timelines[index].getPeriodCount(); + uids[index] = mediaSourceHolder.uid; + childIndexByUid.put(uids[index], index++); + } + this.windowCount = windowCount; + this.periodCount = periodCount; + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false); + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false); + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + Integer index = childIndexByUid.get(childUid); + return index == null ? C.INDEX_UNSET : index; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return timelines[childIndex]; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return firstPeriodInChildIndices[childIndex]; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return firstWindowInChildIndices[childIndex]; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return uids[childIndex]; + } + + @Override + public int getWindowCount() { + return windowCount; + } + + @Override + public int getPeriodCount() { + return periodCount; + } + } + + /** Dummy media source which does nothing and does not support creating periods. */ + private static final class DummyMediaSource extends BaseMediaSource { + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + // Do nothing. + } + + @Override + @Nullable + public Object getTag() { + return null; + } + + @Override + protected void releaseSourceInternal() { + // Do nothing. + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + throw new UnsupportedOperationException(); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + // Do nothing. + } + } + + private static final class HandlerAndRunnable { + + private final Handler handler; + private final Runnable runnable; + + public HandlerAndRunnable(Handler handler, Runnable runnable) { + this.handler = handler; + this.runnable = runnable; + } + + public void dispatch() { + handler.post(runnable); + } + } +} + diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java new file mode 100644 index 0000000000..237510bea3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * Default implementation of {@link CompositeSequenceableLoaderFactory}. + */ +public final class DefaultCompositeSequenceableLoaderFactory + implements CompositeSequenceableLoaderFactory { + + @Override + public SequenceableLoader createCompositeSequenceableLoader(SequenceableLoader... loaders) { + return new CompositeSequenceableLoader(loaders); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java new file mode 100644 index 0000000000..c25750247f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * @deprecated Use {@link MediaSourceEventListener} interface directly for selective overrides as + * all methods are implemented as no-op default methods. + */ +@Deprecated +public abstract class DefaultMediaSourceEventListener implements MediaSourceEventListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java new file mode 100644 index 0000000000..398c6b91fc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import java.io.IOException; + +/** + * An empty {@link SampleStream}. + */ +public final class EmptySampleStream implements SampleStream { + + @Override + public boolean isReady() { + return true; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + + @Override + public int skipData(long positionUs) { + return 0; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java new file mode 100644 index 0000000000..3b72f51c44 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -0,0 +1,394 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** @deprecated Use {@link ProgressiveMediaSource} instead. */ +@Deprecated +@SuppressWarnings("deprecation") +public final class ExtractorMediaSource extends CompositeMediaSource<Void> { + + /** @deprecated Use {@link MediaSourceEventListener} instead. */ + @Deprecated + public interface EventListener { + + /** + * Called when an error occurs loading media data. + * <p> + * This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error and continue. Hence applications should + * <em>not</em> implement this method to display a user visible error or initiate an application + * level retry ({@link Player.EventListener#onPlayerError} is the appropriate place to implement + * such behavior). This method is called to provide the application with an opportunity to log + * the error if it wishes to do so. + * + * @param error The load error. + */ + void onLoadError(IOException error); + + } + + /** @deprecated Use {@link ProgressiveMediaSource.Factory} instead. */ + @Deprecated + public static final class Factory implements MediaSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + @Nullable private ExtractorsFactory extractorsFactory; + @Nullable private String customCacheKey; + @Nullable private Object tag; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private int continueLoadingCheckIntervalBytes; + private boolean isCreateCalled; + + /** + * Creates a new factory for {@link ExtractorMediaSource}s. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + + /** + * Sets the factory for {@link Extractor}s to process the media stream. The default value is an + * instance of {@link DefaultExtractorsFactory}. + * + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those + * formats. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setExtractorsFactory(ExtractorsFactory extractorsFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorsFactory = extractorsFactory; + return this; + } + + /** + * Sets the custom key that uniquely identifies the original stream. Used for cache indexing. + * The default value is {@code null}. + * + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for + * cache indexing. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setCustomCacheKey(String customCacheKey) { + Assertions.checkState(!isCreateCalled); + this.customCacheKey = customCacheKey; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. See {@link + * #setLoadErrorHandlingPolicy} for the default value. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)); + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the number of bytes that should be loaded between each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. The default value is + * {@link #DEFAULT_LOADING_CHECK_INTERVAL_BYTES}. + * + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between + * each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckIntervalBytes) { + Assertions.checkState(!isCreateCalled); + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + return this; + } + + /** @deprecated Use {@link ProgressiveMediaSource.Factory#setDrmSessionManager} instead. */ + @Override + @Deprecated + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + throw new UnsupportedOperationException(); + } + + /** + * Returns a new {@link ExtractorMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @return The new {@link ExtractorMediaSource}. + */ + @Override + public ExtractorMediaSource createMediaSource(Uri uri) { + isCreateCalled = true; + if (extractorsFactory == null) { + extractorsFactory = new DefaultExtractorsFactory(); + } + return new ExtractorMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + loadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + /** + * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * MediaSourceEventListener)} instead. + */ + @Deprecated + public ExtractorMediaSource createMediaSource( + Uri uri, @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { + ExtractorMediaSource mediaSource = createMediaSource(uri); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_OTHER}; + } + } + + /** + * @deprecated Use {@link ProgressiveMediaSource#DEFAULT_LOADING_CHECK_INTERVAL_BYTES} instead. + */ + @Deprecated + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = + ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + + private final ProgressiveMediaSource progressiveMediaSource; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener) { + this(uri, dataSourceFactory, extractorsFactory, eventHandler, eventListener, null); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener, + @Nullable String customCacheKey) { + this( + uri, + dataSourceFactory, + extractorsFactory, + eventHandler, + eventListener, + customCacheKey, + DEFAULT_LOADING_CHECK_INTERVAL_BYTES); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each + * invocation of {@link MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes) { + this( + uri, + dataSourceFactory, + extractorsFactory, + new DefaultLoadErrorHandlingPolicy(), + customCacheKey, + continueLoadingCheckIntervalBytes, + /* tag= */ null); + if (eventListener != null && eventHandler != null) { + addEventListener(eventHandler, new EventListenerWrapper(eventListener)); + } + } + + private ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes, + @Nullable Object tag) { + progressiveMediaSource = + new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + DrmSessionManager.getDummyDrmSessionManager(), + loadableLoadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + @Override + @Nullable + public Object getTag() { + return progressiveMediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, progressiveMediaSource); + } + + @Override + protected void onChildSourceInfoRefreshed( + @Nullable Void id, MediaSource mediaSource, Timeline timeline) { + refreshSourceInfo(timeline); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return progressiveMediaSource.createPeriod(id, allocator, startPositionUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + progressiveMediaSource.releasePeriod(mediaPeriod); + } + + @Deprecated + private static final class EventListenerWrapper implements MediaSourceEventListener { + + private final EventListener eventListener; + + public EventListenerWrapper(EventListener eventListener) { + this.eventListener = Assertions.checkNotNull(eventListener); + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + eventListener.onLoadError(error); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java new file mode 100644 index 0000000000..ce985708d0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; + +/** + * An overridable {@link Timeline} implementation forwarding all methods to another timeline. + */ +public abstract class ForwardingTimeline extends Timeline { + + protected final Timeline timeline; + + public ForwardingTimeline(Timeline timeline) { + this.timeline = timeline; + } + + @Override + public int getWindowCount() { + return timeline.getWindowCount(); + } + + @Override + public int getNextWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + return timeline.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getPreviousWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + return timeline.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + return timeline.getLastWindowIndex(shuffleModeEnabled); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + return timeline.getFirstWindowIndex(shuffleModeEnabled); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + return timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + } + + @Override + public int getPeriodCount() { + return timeline.getPeriodCount(); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + return timeline.getPeriod(periodIndex, period, setIds); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return timeline.getIndexOfPeriod(uid); + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + return timeline.getUidOfPeriod(periodIndex); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java new file mode 100644 index 0000000000..b35525743a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Splits ICY stream metadata out from a stream. + * + * <p>Note: {@link #open(DataSpec)} and {@link #close()} are not supported. This implementation is + * intended to wrap upstream {@link DataSource} instances that are opened and closed directly. + */ +/* package */ final class IcyDataSource implements DataSource { + + public interface Listener { + + /** + * Called when ICY stream metadata has been split from the stream. + * + * @param metadata The stream metadata in binary form. + */ + void onIcyMetadata(ParsableByteArray metadata); + } + + private final DataSource upstream; + private final int metadataIntervalBytes; + private final Listener listener; + private final byte[] metadataLengthByteHolder; + private int bytesUntilMetadata; + + /** + * @param upstream The upstream {@link DataSource}. + * @param metadataIntervalBytes The interval between ICY stream metadata, in bytes. + * @param listener A listener to which stream metadata is delivered. + */ + public IcyDataSource(DataSource upstream, int metadataIntervalBytes, Listener listener) { + Assertions.checkArgument(metadataIntervalBytes > 0); + this.upstream = upstream; + this.metadataIntervalBytes = metadataIntervalBytes; + this.listener = listener; + metadataLengthByteHolder = new byte[1]; + bytesUntilMetadata = metadataIntervalBytes; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + if (bytesUntilMetadata == 0) { + if (readMetadata()) { + bytesUntilMetadata = metadataIntervalBytes; + } else { + return C.RESULT_END_OF_INPUT; + } + } + int bytesRead = upstream.read(buffer, offset, Math.min(bytesUntilMetadata, readLength)); + if (bytesRead != C.RESULT_END_OF_INPUT) { + bytesUntilMetadata -= bytesRead; + } + return bytesRead; + } + + @Nullable + @Override + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + throw new UnsupportedOperationException(); + } + + /** + * Reads an ICY stream metadata block, passing it to {@link #listener} unless the block is empty. + * + * @return True if the block was extracted, including if its length byte indicated a length of + * zero. False if the end of the stream was reached. + * @throws IOException If an error occurs reading from the wrapped {@link DataSource}. + */ + private boolean readMetadata() throws IOException { + int bytesRead = upstream.read(metadataLengthByteHolder, 0, 1); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + int metadataLength = (metadataLengthByteHolder[0] & 0xFF) << 4; + if (metadataLength == 0) { + return true; + } + + int offset = 0; + int lengthRemaining = metadataLength; + byte[] metadata = new byte[metadataLength]; + while (lengthRemaining > 0) { + bytesRead = upstream.read(metadata, offset, lengthRemaining); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + offset += bytesRead; + lengthRemaining -= bytesRead; + } + + // Discard trailing zero bytes. + while (metadataLength > 0 && metadata[metadataLength - 1] == 0) { + metadataLength--; + } + + if (metadataLength > 0) { + listener.onIcyMetadata(new ParsableByteArray(metadata, metadataLength)); + } + return true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java new file mode 100644 index 0000000000..880bfd6a4f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.HashMap; +import java.util.Map; + +/** + * Loops a {@link MediaSource} a specified number of times. + * + * <p>Note: To loop a {@link MediaSource} indefinitely, it is usually better to use {@link + * ExoPlayer#setRepeatMode(int)} instead of this class. + */ +public final class LoopingMediaSource extends CompositeMediaSource<Void> { + + private final MediaSource childSource; + private final int loopCount; + private final Map<MediaPeriodId, MediaPeriodId> childMediaPeriodIdToMediaPeriodId; + private final Map<MediaPeriod, MediaPeriodId> mediaPeriodToChildMediaPeriodId; + + /** + * Loops the provided source indefinitely. Note that it is usually better to use + * {@link ExoPlayer#setRepeatMode(int)}. + * + * @param childSource The {@link MediaSource} to loop. + */ + public LoopingMediaSource(MediaSource childSource) { + this(childSource, Integer.MAX_VALUE); + } + + /** + * Loops the provided source a specified number of times. + * + * @param childSource The {@link MediaSource} to loop. + * @param loopCount The desired number of loops. Must be strictly positive. + */ + public LoopingMediaSource(MediaSource childSource, int loopCount) { + Assertions.checkArgument(loopCount > 0); + this.childSource = childSource; + this.loopCount = loopCount; + childMediaPeriodIdToMediaPeriodId = new HashMap<>(); + mediaPeriodToChildMediaPeriodId = new HashMap<>(); + } + + @Override + @Nullable + public Object getTag() { + return childSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, childSource); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + if (loopCount == Integer.MAX_VALUE) { + return childSource.createPeriod(id, allocator, startPositionUs); + } + Object childPeriodUid = LoopingTimeline.getChildPeriodUidFromConcatenatedUid(id.periodUid); + MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(childPeriodUid); + childMediaPeriodIdToMediaPeriodId.put(childMediaPeriodId, id); + MediaPeriod mediaPeriod = + childSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaPeriodToChildMediaPeriodId.put(mediaPeriod, childMediaPeriodId); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + childSource.releasePeriod(mediaPeriod); + MediaPeriodId childMediaPeriodId = mediaPeriodToChildMediaPeriodId.remove(mediaPeriod); + if (childMediaPeriodId != null) { + childMediaPeriodIdToMediaPeriodId.remove(childMediaPeriodId); + } + } + + @Override + protected void onChildSourceInfoRefreshed(Void id, MediaSource mediaSource, Timeline timeline) { + Timeline loopingTimeline = + loopCount != Integer.MAX_VALUE + ? new LoopingTimeline(timeline, loopCount) + : new InfinitelyLoopingTimeline(timeline); + refreshSourceInfo(loopingTimeline); + } + + @Override + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Void id, MediaPeriodId mediaPeriodId) { + return loopCount != Integer.MAX_VALUE + ? childMediaPeriodIdToMediaPeriodId.get(mediaPeriodId) + : mediaPeriodId; + } + + private static final class LoopingTimeline extends AbstractConcatenatedTimeline { + + private final Timeline childTimeline; + private final int childPeriodCount; + private final int childWindowCount; + private final int loopCount; + + public LoopingTimeline(Timeline childTimeline, int loopCount) { + super(/* isAtomic= */ false, new UnshuffledShuffleOrder(loopCount)); + this.childTimeline = childTimeline; + childPeriodCount = childTimeline.getPeriodCount(); + childWindowCount = childTimeline.getWindowCount(); + this.loopCount = loopCount; + if (childPeriodCount > 0) { + Assertions.checkState(loopCount <= Integer.MAX_VALUE / childPeriodCount, + "LoopingMediaSource contains too many periods"); + } + } + + @Override + public int getWindowCount() { + return childWindowCount * loopCount; + } + + @Override + public int getPeriodCount() { + return childPeriodCount * loopCount; + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return periodIndex / childPeriodCount; + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return windowIndex / childWindowCount; + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + if (!(childUid instanceof Integer)) { + return C.INDEX_UNSET; + } + return (Integer) childUid; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return childTimeline; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return childIndex * childPeriodCount; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return childIndex * childWindowCount; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return childIndex; + } + + } + + private static final class InfinitelyLoopingTimeline extends ForwardingTimeline { + + public InfinitelyLoopingTimeline(Timeline timeline) { + super(timeline); + } + + @Override + public int getNextWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + int childNextWindowIndex = timeline.getNextWindowIndex(windowIndex, repeatMode, + shuffleModeEnabled); + return childNextWindowIndex == C.INDEX_UNSET ? getFirstWindowIndex(shuffleModeEnabled) + : childNextWindowIndex; + } + + @Override + public int getPreviousWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + int childPreviousWindowIndex = timeline.getPreviousWindowIndex(windowIndex, repeatMode, + shuffleModeEnabled); + return childPreviousWindowIndex == C.INDEX_UNSET ? getLastWindowIndex(shuffleModeEnabled) + : childPreviousWindowIndex; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java new file mode 100644 index 0000000000..4fe7b137b6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import java.io.IOException; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Media period that wraps a media source and defers calling its {@link + * MediaSource#createPeriod(MediaPeriodId, Allocator, long)} method until {@link + * #createPeriod(MediaPeriodId)} has been called. This is useful if you need to return a media + * period immediately but the media source that should create it is not yet prepared. + */ +public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + /** Listener for preparation errors. */ + public interface PrepareErrorListener { + + /** + * Called the first time an error occurs while refreshing source info or preparing the period. + */ + void onPrepareError(MediaPeriodId mediaPeriodId, IOException exception); + } + + /** The {@link MediaSource} which will create the actual media period. */ + public final MediaSource mediaSource; + /** The {@link MediaPeriodId} used to create the masking media period. */ + public final MediaPeriodId id; + + private final Allocator allocator; + + @Nullable private MediaPeriod mediaPeriod; + @Nullable private Callback callback; + private long preparePositionUs; + @Nullable private PrepareErrorListener listener; + private boolean notifiedPrepareError; + private long preparePositionOverrideUs; + + /** + * Creates a new masking media period. + * + * @param mediaSource The media source to wrap. + * @param id The identifier used to create the masking media period. + * @param allocator The allocator used to create the media period. + * @param preparePositionUs The expected start position, in microseconds. + */ + public MaskingMediaPeriod( + MediaSource mediaSource, MediaPeriodId id, Allocator allocator, long preparePositionUs) { + this.id = id; + this.allocator = allocator; + this.mediaSource = mediaSource; + this.preparePositionUs = preparePositionUs; + preparePositionOverrideUs = C.TIME_UNSET; + } + + /** + * Sets a listener for preparation errors. + * + * @param listener An listener to be notified of media period preparation errors. If a listener is + * set, {@link #maybeThrowPrepareError()} will not throw but will instead pass the first + * preparation error (if any) to the listener. + */ + public void setPrepareErrorListener(PrepareErrorListener listener) { + this.listener = listener; + } + + /** Returns the position at which the masking media period was prepared, in microseconds. */ + public long getPreparePositionUs() { + return preparePositionUs; + } + + /** + * Overrides the default prepare position at which to prepare the media period. This value is only + * used if called before {@link #createPeriod(MediaPeriodId)}. + * + * @param preparePositionUs The default prepare position to use, in microseconds. + */ + public void overridePreparePositionUs(long preparePositionUs) { + preparePositionOverrideUs = preparePositionUs; + } + + /** + * Calls {@link MediaSource#createPeriod(MediaPeriodId, Allocator, long)} on the wrapped source + * then prepares it if {@link #prepare(Callback, long)} has been called. Call {@link + * #releasePeriod()} to release the period. + * + * @param id The identifier that should be used to create the media period from the media source. + */ + public void createPeriod(MediaPeriodId id) { + long preparePositionUs = getPreparePositionWithOverride(this.preparePositionUs); + mediaPeriod = mediaSource.createPeriod(id, allocator, preparePositionUs); + if (callback != null) { + mediaPeriod.prepare(this, preparePositionUs); + } + } + + /** + * Releases the period. + */ + public void releasePeriod() { + if (mediaPeriod != null) { + mediaSource.releasePeriod(mediaPeriod); + } + } + + @Override + public void prepare(Callback callback, long preparePositionUs) { + this.callback = callback; + if (mediaPeriod != null) { + mediaPeriod.prepare(this, getPreparePositionWithOverride(this.preparePositionUs)); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + try { + if (mediaPeriod != null) { + mediaPeriod.maybeThrowPrepareError(); + } else { + mediaSource.maybeThrowSourceInfoRefreshError(); + } + } catch (final IOException e) { + if (listener == null) { + throw e; + } + if (!notifiedPrepareError) { + notifiedPrepareError = true; + listener.onPrepareError(id, e); + } + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return castNonNull(mediaPeriod).getTrackGroups(); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + if (preparePositionOverrideUs != C.TIME_UNSET && positionUs == preparePositionUs) { + positionUs = preparePositionOverrideUs; + preparePositionOverrideUs = C.TIME_UNSET; + } + return castNonNull(mediaPeriod) + .selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs); + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + castNonNull(mediaPeriod).discardBuffer(positionUs, toKeyframe); + } + + @Override + public long readDiscontinuity() { + return castNonNull(mediaPeriod).readDiscontinuity(); + } + + @Override + public long getBufferedPositionUs() { + return castNonNull(mediaPeriod).getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + return castNonNull(mediaPeriod).seekToUs(positionUs); + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return castNonNull(mediaPeriod).getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + @Override + public long getNextLoadPositionUs() { + return castNonNull(mediaPeriod).getNextLoadPositionUs(); + } + + @Override + public void reevaluateBuffer(long positionUs) { + castNonNull(mediaPeriod).reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod != null && mediaPeriod.continueLoading(positionUs); + } + + @Override + public boolean isLoading() { + return mediaPeriod != null && mediaPeriod.isLoading(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + castNonNull(callback).onContinueLoadingRequested(this); + } + + // MediaPeriod.Callback implementation + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + castNonNull(callback).onPrepared(this); + } + + private long getPreparePositionWithOverride(long preparePositionUs) { + return preparePositionOverrideUs != C.TIME_UNSET + ? preparePositionOverrideUs + : preparePositionUs; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java new file mode 100644 index 0000000000..8c867a8c26 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -0,0 +1,353 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link MediaSource} that masks the {@link Timeline} with a placeholder until the actual media + * structure is known. + */ +public final class MaskingMediaSource extends CompositeMediaSource<Void> { + + private final MediaSource mediaSource; + private final boolean useLazyPreparation; + private final Timeline.Window window; + private final Timeline.Period period; + + private MaskingTimeline timeline; + @Nullable private MaskingMediaPeriod unpreparedMaskingMediaPeriod; + @Nullable private EventDispatcher unpreparedMaskingMediaPeriodEventDispatcher; + private boolean hasStartedPreparing; + private boolean isPrepared; + + /** + * Creates the masking media source. + * + * @param mediaSource A {@link MediaSource}. + * @param useLazyPreparation Whether the {@code mediaSource} is prepared lazily. If false, all + * manifest loads and other initial preparation steps happen immediately. If true, these + * initial preparations are triggered only when the player starts buffering the media. + */ + public MaskingMediaSource(MediaSource mediaSource, boolean useLazyPreparation) { + this.mediaSource = mediaSource; + this.useLazyPreparation = useLazyPreparation; + window = new Timeline.Window(); + period = new Timeline.Period(); + timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + } + + /** Returns the {@link Timeline}. */ + public Timeline getTimeline() { + return timeline; + } + + @Override + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + if (!useLazyPreparation) { + hasStartedPreparing = true; + prepareChildSource(/* id= */ null, mediaSource); + } + } + + @Nullable + @Override + public Object getTag() { + return mediaSource.getTag(); + } + + @Override + @SuppressWarnings("MissingSuperCall") + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. Source info refresh errors will be thrown when calling + // MaskingMediaPeriod.maybeThrowPrepareError. + } + + @Override + public MaskingMediaPeriod createPeriod( + MediaPeriodId id, Allocator allocator, long startPositionUs) { + MaskingMediaPeriod mediaPeriod = + new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); + if (isPrepared) { + MediaPeriodId idInSource = id.copyWithPeriodUid(getInternalPeriodUid(id.periodUid)); + mediaPeriod.createPeriod(idInSource); + } else { + // We should have at most one media period while source is unprepared because the duration is + // unset and we don't load beyond periods with unset duration. We need to figure out how to + // handle the prepare positions of multiple deferred media periods, should that ever change. + unpreparedMaskingMediaPeriod = mediaPeriod; + unpreparedMaskingMediaPeriodEventDispatcher = + createEventDispatcher(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0); + unpreparedMaskingMediaPeriodEventDispatcher.mediaPeriodCreated(); + if (!hasStartedPreparing) { + hasStartedPreparing = true; + prepareChildSource(/* id= */ null, mediaSource); + } + } + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((MaskingMediaPeriod) mediaPeriod).releasePeriod(); + if (mediaPeriod == unpreparedMaskingMediaPeriod) { + Assertions.checkNotNull(unpreparedMaskingMediaPeriodEventDispatcher).mediaPeriodReleased(); + unpreparedMaskingMediaPeriodEventDispatcher = null; + unpreparedMaskingMediaPeriod = null; + } + } + + @Override + public void releaseSourceInternal() { + isPrepared = false; + hasStartedPreparing = false; + super.releaseSourceInternal(); + } + + @Override + protected void onChildSourceInfoRefreshed( + Void id, MediaSource mediaSource, Timeline newTimeline) { + if (isPrepared) { + timeline = timeline.cloneWithUpdatedTimeline(newTimeline); + } else if (newTimeline.isEmpty()) { + timeline = + MaskingTimeline.createWithRealTimeline( + newTimeline, Window.SINGLE_WINDOW_UID, MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID); + } else { + // Determine first period and the start position. + // This will be: + // 1. The default window start position if no deferred period has been created yet. + // 2. The non-zero prepare position of the deferred period under the assumption that this is + // a non-zero initial seek position in the window. + // 3. The default window start position if the deferred period has a prepare position of zero + // under the assumption that the prepare position of zero was used because it's the + // default position of the DummyTimeline window. Note that this will override an + // intentional seek to zero for a window with a non-zero default position. This is + // unlikely to be a problem as a non-zero default position usually only occurs for live + // playbacks and seeking to zero in a live window would cause BehindLiveWindowExceptions + // anyway. + newTimeline.getWindow(/* windowIndex= */ 0, window); + long windowStartPositionUs = window.getDefaultPositionUs(); + if (unpreparedMaskingMediaPeriod != null) { + long periodPreparePositionUs = unpreparedMaskingMediaPeriod.getPreparePositionUs(); + if (periodPreparePositionUs != 0) { + windowStartPositionUs = periodPreparePositionUs; + } + } + Object windowUid = window.uid; + Pair<Object, Long> periodPosition = + newTimeline.getPeriodPosition( + window, period, /* windowIndex= */ 0, windowStartPositionUs); + Object periodUid = periodPosition.first; + long periodPositionUs = periodPosition.second; + timeline = MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid); + if (unpreparedMaskingMediaPeriod != null) { + MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod; + maskingPeriod.overridePreparePositionUs(periodPositionUs); + MediaPeriodId idInSource = + maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid)); + maskingPeriod.createPeriod(idInSource); + } + } + isPrepared = true; + refreshSourceInfo(this.timeline); + } + + @Nullable + @Override + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Void id, MediaPeriodId mediaPeriodId) { + return mediaPeriodId.copyWithPeriodUid(getExternalPeriodUid(mediaPeriodId.periodUid)); + } + + @Override + protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { + // Suppress create and release events for the period created while the source was still + // unprepared, as we send these events from this class. + return unpreparedMaskingMediaPeriod == null + || !mediaPeriodId.equals(unpreparedMaskingMediaPeriod.id); + } + + private Object getInternalPeriodUid(Object externalPeriodUid) { + return externalPeriodUid.equals(MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID) + ? timeline.replacedInternalPeriodUid + : externalPeriodUid; + } + + private Object getExternalPeriodUid(Object internalPeriodUid) { + return timeline.replacedInternalPeriodUid.equals(internalPeriodUid) + ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID + : internalPeriodUid; + } + + /** + * Timeline used as placeholder for an unprepared media source. After preparation, a + * MaskingTimeline is used to keep the originally assigned dummy period ID. + */ + private static final class MaskingTimeline extends ForwardingTimeline { + + public static final Object DUMMY_EXTERNAL_PERIOD_UID = new Object(); + + private final Object replacedInternalWindowUid; + private final Object replacedInternalPeriodUid; + + /** + * Returns an instance with a dummy timeline using the provided window tag. + * + * @param windowTag A window tag. + */ + public static MaskingTimeline createWithDummyTimeline(@Nullable Object windowTag) { + return new MaskingTimeline( + new DummyTimeline(windowTag), Window.SINGLE_WINDOW_UID, DUMMY_EXTERNAL_PERIOD_UID); + } + + /** + * Returns an instance with a real timeline, replacing the provided period ID with the already + * assigned dummy period ID. + * + * @param timeline The real timeline. + * @param firstWindowUid The window UID in the timeline which will be replaced by the already + * assigned {@link Window#SINGLE_WINDOW_UID}. + * @param firstPeriodUid The period UID in the timeline which will be replaced by the already + * assigned {@link #DUMMY_EXTERNAL_PERIOD_UID}. + */ + public static MaskingTimeline createWithRealTimeline( + Timeline timeline, Object firstWindowUid, Object firstPeriodUid) { + return new MaskingTimeline(timeline, firstWindowUid, firstPeriodUid); + } + + private MaskingTimeline( + Timeline timeline, Object replacedInternalWindowUid, Object replacedInternalPeriodUid) { + super(timeline); + this.replacedInternalWindowUid = replacedInternalWindowUid; + this.replacedInternalPeriodUid = replacedInternalPeriodUid; + } + + /** + * Returns a copy with an updated timeline. This keeps the existing period replacement. + * + * @param timeline The new timeline. + */ + public MaskingTimeline cloneWithUpdatedTimeline(Timeline timeline) { + return new MaskingTimeline(timeline, replacedInternalWindowUid, replacedInternalPeriodUid); + } + + /** Returns the wrapped timeline. */ + public Timeline getTimeline() { + return timeline; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + if (Util.areEqual(window.uid, replacedInternalWindowUid)) { + window.uid = Window.SINGLE_WINDOW_UID; + } + return window; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(periodIndex, period, setIds); + if (Util.areEqual(period.uid, replacedInternalPeriodUid)) { + period.uid = DUMMY_EXTERNAL_PERIOD_UID; + } + return period; + } + + @Override + public int getIndexOfPeriod(Object uid) { + return timeline.getIndexOfPeriod( + DUMMY_EXTERNAL_PERIOD_UID.equals(uid) ? replacedInternalPeriodUid : uid); + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + Object uid = timeline.getUidOfPeriod(periodIndex); + return Util.areEqual(uid, replacedInternalPeriodUid) ? DUMMY_EXTERNAL_PERIOD_UID : uid; + } + } + + /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ + private static final class DummyTimeline extends Timeline { + + @Nullable private final Object tag; + + public DummyTimeline(@Nullable Object tag) { + this.tag = tag; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + return window.set( + Window.SINGLE_WINDOW_UID, + tag, + /* manifest= */ null, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* isSeekable= */ false, + // Dynamic window to indicate pending timeline updates. + /* isDynamic= */ true, + /* isLive= */ false, + /* defaultPositionUs= */ 0, + /* durationUs= */ C.TIME_UNSET, + /* firstPeriodIndex= */ 0, + /* lastPeriodIndex= */ 0, + /* positionInFirstPeriodUs= */ 0); + } + + @Override + public int getPeriodCount() { + return 1; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + return period.set( + /* id= */ 0, + /* uid= */ MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID, + /* windowIndex= */ 0, + /* durationUs = */ C.TIME_UNSET, + /* positionInWindowUs= */ 0); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return uid == MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID ? 0 : C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + return MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java new file mode 100644 index 0000000000..3effcec904 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Loads media corresponding to a {@link Timeline.Period}, and allows that media to be read. All + * methods are called on the player's internal playback thread, as described in the + * {@link ExoPlayer} Javadoc. + */ +public interface MediaPeriod extends SequenceableLoader { + + /** + * A callback to be notified of {@link MediaPeriod} events. + */ + interface Callback extends SequenceableLoader.Callback<MediaPeriod> { + + /** + * Called when preparation completes. + * + * <p>Called on the playback thread. After invoking this method, the {@link MediaPeriod} can + * expect for {@link #selectTracks(TrackSelection[], boolean[], SampleStream[], boolean[], + * long)} to be called with the initial track selection. + * + * @param mediaPeriod The prepared {@link MediaPeriod}. + */ + void onPrepared(MediaPeriod mediaPeriod); + } + + /** + * Prepares this media period asynchronously. + * + * <p>{@code callback.onPrepared} is called when preparation completes. If preparation fails, + * {@link #maybeThrowPrepareError()} will throw an {@link IOException}. + * + * <p>If preparation succeeds and results in a source timeline change (e.g. the period duration + * becoming known), {@link MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} will be + * called before {@code callback.onPrepared}. + * + * @param callback Callback to receive updates from this period, including being notified when + * preparation completes. + * @param positionUs The expected starting position, in microseconds. + */ + void prepare(Callback callback, long positionUs); + + /** + * Throws an error that's preventing the period from becoming prepared. Does nothing if no such + * error exists. + * + * <p>This method is only called before the period has completed preparation. + * + * @throws IOException The underlying error. + */ + void maybeThrowPrepareError() throws IOException; + + /** + * Returns the {@link TrackGroup}s exposed by the period. + * + * <p>This method is only called after the period has been prepared. + * + * @return The {@link TrackGroup}s. + */ + TrackGroupArray getTrackGroups(); + + /** + * Returns a list of {@link StreamKey StreamKeys} which allow to filter the media in this period + * to load only the parts needed to play the provided {@link TrackSelection TrackSelections}. + * + * <p>This method is only called after the period has been prepared. + * + * @param trackSelections The {@link TrackSelection TrackSelections} describing the tracks for + * which stream keys are requested. + * @return The corresponding {@link StreamKey StreamKeys} for the selected tracks, or an empty + * list if filtering is not possible and the entire media needs to be loaded to play the + * selected tracks. + */ + default List<StreamKey> getStreamKeys(List<TrackSelection> trackSelections) { + return Collections.emptyList(); + } + + /** + * Performs a track selection. + * + * <p>The call receives track {@code selections} for each renderer, {@code mayRetainStreamFlags} + * indicating whether the existing {@link SampleStream} can be retained for each selection, and + * the existing {@code stream}s themselves. The call will update {@code streams} to reflect the + * provided selections, clearing, setting and replacing entries as required. If an existing sample + * stream is retained but with the requirement that the consuming renderer be reset, then the + * corresponding flag in {@code streamResetFlags} will be set to true. This flag will also be set + * if a new sample stream is created. + * + * <p>Note that previously passed {@link TrackSelection TrackSelections} are no longer valid, and + * any references to them must be updated to point to the new selections. + * + * <p>This method is only called after the period has been prepared. + * + * @param selections The renderer track selections. + * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained + * for each track selection. A {@code true} value indicates that the selection is equivalent + * to the one that was previously passed, and that the caller does not require that the sample + * stream be recreated. If a retained sample stream holds any references to the track + * selection then they must be updated to point to the new selection. + * @param streams The existing sample streams, which will be updated to reflect the provided + * selections. + * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that + * have been retained but with the requirement that the consuming renderer be reset. + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position. + * @return The actual position at which the tracks were enabled, in microseconds. + */ + long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs); + + /** + * Discards buffered media up to the specified position. + * + * <p>This method is only called after the period has been prepared. + * + * @param positionUs The position in microseconds. + * @param toKeyframe If true then for each track discards samples up to the keyframe before or at + * the specified position, rather than any sample before or at that position. + */ + void discardBuffer(long positionUs, boolean toKeyframe); + + /** + * Attempts to read a discontinuity. + * + * <p>After this method has returned a value other than {@link C#TIME_UNSET}, all {@link + * SampleStream}s provided by the period are guaranteed to start from a key frame. + * + * <p>This method is only called after the period has been prepared and before reading from any + * {@link SampleStream}s provided by the period. + * + * @return If a discontinuity was read then the playback position in microseconds after the + * discontinuity. Else {@link C#TIME_UNSET}. + */ + long readDiscontinuity(); + + /** + * Attempts to seek to the specified position in microseconds. + * + * <p>After this method has been called, all {@link SampleStream}s provided by the period are + * guaranteed to start from a key frame. + * + * <p>This method is only called when at least one track is selected. + * + * @param positionUs The seek position in microseconds. + * @return The actual position to which the period was seeked, in microseconds. + */ + long seekToUs(long positionUs); + + /** + * Returns the position to which a seek will be performed, given the specified seek position and + * {@link SeekParameters}. + * + * <p>This method is only called after the period has been prepared. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. Implementations may + * apply seek parameters on a best effort basis. + * @return The actual position to which a seek will be performed, in microseconds. + */ + long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters); + + // SequenceableLoader interface. Overridden to provide more specific documentation. + + /** + * Returns an estimate of the position up to which data is buffered for the enabled tracks. + * + * <p>This method is only called when at least one track is selected. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the track is fully buffered. + */ + @Override + long getBufferedPositionUs(); + + /** + * Returns the next load time, or {@link C#TIME_END_OF_SOURCE} if loading has finished. + * + * <p>This method is only called after the period has been prepared. It may be called when no + * tracks are selected. + */ + @Override + long getNextLoadPositionUs(); + + /** + * Attempts to continue loading. + * + * <p>This method may be called both during and after the period has been prepared. + * + * <p>A period may call {@link Callback#onContinueLoadingRequested(SequenceableLoader)} on the + * {@link Callback} passed to {@link #prepare(Callback, long)} to request that this method be + * called when the period is permitted to continue loading data. A period may do this both during + * and after preparation. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return a + * different value than prior to the call. False otherwise. + */ + @Override + boolean continueLoading(long positionUs); + + /** Returns whether the media period is currently loading. */ + boolean isLoading(); + + /** + * Re-evaluates the buffer given the playback position. + * + * <p>This method is only called after the period has been prepared. + * + * <p>A period may choose to discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + @Override + void reevaluateBuffer(long positionUs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java new file mode 100644 index 0000000000..7e757d5ade --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; + +/** + * Defines and provides media to be played by an {@link org.mozilla.thirdparty.com.google.android.exoplayer2ExoPlayer}. A + * MediaSource has two main responsibilities: + * + * <ul> + * <li>To provide the player with a {@link Timeline} defining the structure of its media, and to + * provide a new timeline whenever the structure of the media changes. The MediaSource + * provides these timelines by calling {@link MediaSourceCaller#onSourceInfoRefreshed} on the + * {@link MediaSourceCaller}s passed to {@link #prepareSource(MediaSourceCaller, + * TransferListener)}. + * <li>To provide {@link MediaPeriod} instances for the periods in its timeline. MediaPeriods are + * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator, long)}, and provide a + * way for the player to load and read the media. + * </ul> + * + * All methods are called on the player's internal playback thread, as described in the {@link + * com.google.android.exoplayer2.ExoPlayer} Javadoc. They should not be called directly from + * application code. Instances can be re-used, but only for one {@link + * com.google.android.exoplayer2.ExoPlayer} instance simultaneously. + */ +public interface MediaSource { + + /** A caller of media sources, which will be notified of source events. */ + interface MediaSourceCaller { + + /** + * Called when the {@link Timeline} has been refreshed. + * + * <p>Called on the playback thread. + * + * @param source The {@link MediaSource} whose info has been refreshed. + * @param timeline The source's timeline. + */ + void onSourceInfoRefreshed(MediaSource source, Timeline timeline); + } + + /** Identifier for a {@link MediaPeriod}. */ + final class MediaPeriodId { + + /** The unique id of the timeline period. */ + public final Object periodUid; + + /** + * If the media period is in an ad group, the index of the ad group in the period. + * {@link C#INDEX_UNSET} otherwise. + */ + public final int adGroupIndex; + + /** + * If the media period is in an ad group, the index of the ad in its ad group in the period. + * {@link C#INDEX_UNSET} otherwise. + */ + public final int adIndexInAdGroup; + + /** + * The sequence number of the window in the buffered sequence of windows this media period is + * part of. {@link C#INDEX_UNSET} if the media period id is not part of a buffered sequence of + * windows. + */ + public final long windowSequenceNumber; + + /** + * The index of the next ad group to which the media period's content is clipped, or {@link + * C#INDEX_UNSET} if there is no following ad group or if this media period is an ad. + */ + public final int nextAdGroupIndex; + + /** + * Creates a media period identifier for a dummy period which is not part of a buffered sequence + * of windows. + * + * @param periodUid The unique id of the timeline period. + */ + public MediaPeriodId(Object periodUid) { + this(periodUid, /* windowSequenceNumber= */ C.INDEX_UNSET); + } + + /** + * Creates a media period identifier for the specified period in the timeline. + * + * @param periodUid The unique id of the timeline period. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + */ + public MediaPeriodId(Object periodUid, long windowSequenceNumber) { + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + } + + /** + * Creates a media period identifier for the specified clipped period in the timeline. + * + * @param periodUid The unique id of the timeline period. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + * @param nextAdGroupIndex The index of the next ad group to which the media period's content is + * clipped. + */ + public MediaPeriodId(Object periodUid, long windowSequenceNumber, int nextAdGroupIndex) { + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + nextAdGroupIndex); + } + + /** + * Creates a media period identifier that identifies an ad within an ad group at the specified + * timeline period. + * + * @param periodUid The unique id of the timeline period that contains the ad group. + * @param adGroupIndex The index of the ad group. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + */ + public MediaPeriodId( + Object periodUid, int adGroupIndex, int adIndexInAdGroup, long windowSequenceNumber) { + this( + periodUid, + adGroupIndex, + adIndexInAdGroup, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + } + + private MediaPeriodId( + Object periodUid, + int adGroupIndex, + int adIndexInAdGroup, + long windowSequenceNumber, + int nextAdGroupIndex) { + this.periodUid = periodUid; + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + this.windowSequenceNumber = windowSequenceNumber; + this.nextAdGroupIndex = nextAdGroupIndex; + } + + /** Returns a copy of this period identifier but with {@code newPeriodUid} as its period uid. */ + public MediaPeriodId copyWithPeriodUid(Object newPeriodUid) { + return periodUid.equals(newPeriodUid) + ? this + : new MediaPeriodId( + newPeriodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber, nextAdGroupIndex); + } + + /** + * Returns whether this period identifier identifies an ad in an ad group in a period. + */ + public boolean isAd() { + return adGroupIndex != C.INDEX_UNSET; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + MediaPeriodId periodId = (MediaPeriodId) obj; + return periodUid.equals(periodId.periodUid) + && adGroupIndex == periodId.adGroupIndex + && adIndexInAdGroup == periodId.adIndexInAdGroup + && windowSequenceNumber == periodId.windowSequenceNumber + && nextAdGroupIndex == periodId.nextAdGroupIndex; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + periodUid.hashCode(); + result = 31 * result + adGroupIndex; + result = 31 * result + adIndexInAdGroup; + result = 31 * result + (int) windowSequenceNumber; + result = 31 * result + nextAdGroupIndex; + return result; + } + } + + /** + * Adds a {@link MediaSourceEventListener} to the list of listeners which are notified of media + * source events. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + void addEventListener(Handler handler, MediaSourceEventListener eventListener); + + /** + * Removes a {@link MediaSourceEventListener} from the list of listeners which are notified of + * media source events. + * + * @param eventListener The listener to be removed. + */ + void removeEventListener(MediaSourceEventListener eventListener); + + /** Returns the tag set on the media source, or null if none was set. */ + @Nullable + default Object getTag() { + return null; + } + + /** + * Registers a {@link MediaSourceCaller}. Starts source preparation if needed and enables the + * source for the creation of {@link MediaPeriod MediaPerods}. + * + * <p>Should not be called directly from application code. + * + * <p>{@link MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} will be called once + * the source has a {@link Timeline}. + * + * <p>For each call to this method, a call to {@link #releaseSource(MediaSourceCaller)} is needed + * to remove the caller and to release the source if no longer required. + * + * @param caller The {@link MediaSourceCaller} to be registered. + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. Note that this listener should be only + * informed of transfers related to the media loads and not of auxiliary loads for manifests + * and other data. + */ + void prepareSource(MediaSourceCaller caller, @Nullable TransferListener mediaTransferListener); + + /** + * Throws any pending error encountered while loading or refreshing source information. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after {@link #prepareSource(MediaSourceCaller, TransferListener)}. + */ + void maybeThrowSourceInfoRefreshError() throws IOException; + + /** + * Enables the source for the creation of {@link MediaPeriod MediaPeriods}. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after {@link #prepareSource(MediaSourceCaller, TransferListener)}. + * + * @param caller The {@link MediaSourceCaller} enabling the source. + */ + void enable(MediaSourceCaller caller); + + /** + * Returns a new {@link MediaPeriod} identified by {@code periodId}. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called if the source is enabled. + * + * @param id The identifier of the period. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. + * @return A new {@link MediaPeriod}. + */ + MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs); + + /** + * Releases the period. + * + * <p>Should not be called directly from application code. + * + * @param mediaPeriod The period to release. + */ + void releasePeriod(MediaPeriod mediaPeriod); + + /** + * Disables the source for the creation of {@link MediaPeriod MediaPeriods}. The implementation + * should not hold onto limited resources used for the creation of media periods. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after all {@link MediaPeriod MediaPeriods} previously created by {@link + * #createPeriod(MediaPeriodId, Allocator, long)} have been released by {@link + * #releasePeriod(MediaPeriod)}. + * + * @param caller The {@link MediaSourceCaller} disabling the source. + */ + void disable(MediaSourceCaller caller); + + /** + * Unregisters a caller, and disables and releases the source if no longer required. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called if all created {@link MediaPeriod MediaPeriods} have been released by + * {@link #releasePeriod(MediaPeriod)}. + * + * @param caller The {@link MediaSourceCaller} to be unregistered. + */ + void releaseSource(MediaSourceCaller caller); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java new file mode 100644 index 0000000000..53c50d8a26 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java @@ -0,0 +1,740 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Interface for callbacks to be notified of {@link MediaSource} events. */ +public interface MediaSourceEventListener { + + /** Media source load event information. */ + final class LoadEventInfo { + + /** Defines the requested data. */ + public final DataSpec dataSpec; + /** + * The {@link Uri} from which data is being read. The uri will be identical to the one in {@link + * #dataSpec}.uri unless redirection has occurred. If redirection has occurred, this is the uri + * after redirection. + */ + public final Uri uri; + /** The response headers associated with the load, or an empty map if unavailable. */ + public final Map<String, List<String>> responseHeaders; + /** The value of {@link SystemClock#elapsedRealtime} at the time of the load event. */ + public final long elapsedRealtimeMs; + /** The duration of the load up to the event time. */ + public final long loadDurationMs; + /** The number of bytes that were loaded up to the event time. */ + public final long bytesLoaded; + + /** + * Creates load event info. + * + * @param dataSpec Defines the requested data. + * @param uri The {@link Uri} from which data is being read. The uri must be identical to the + * one in {@code dataSpec.uri} unless redirection has occurred. If redirection has occurred, + * this is the uri after redirection. + * @param responseHeaders The response headers associated with the load, or an empty map if + * unavailable. + * @param elapsedRealtimeMs The value of {@link SystemClock#elapsedRealtime} at the time of the + * load event. + * @param loadDurationMs The duration of the load up to the event time. + * @param bytesLoaded The number of bytes that were loaded up to the event time. For compressed + * network responses, this is the decompressed size. + */ + public LoadEventInfo( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + this.dataSpec = dataSpec; + this.uri = uri; + this.responseHeaders = responseHeaders; + this.elapsedRealtimeMs = elapsedRealtimeMs; + this.loadDurationMs = loadDurationMs; + this.bytesLoaded = bytesLoaded; + } + } + + /** Descriptor for data being loaded or selected by a media source. */ + final class MediaLoadData { + + /** One of the {@link C} {@code DATA_TYPE_*} constants defining the type of data. */ + public final int dataType; + /** + * One of the {@link C} {@code TRACK_TYPE_*} constants if the data corresponds to media of a + * specific type. {@link C#TRACK_TYPE_UNKNOWN} otherwise. + */ + public final int trackType; + /** + * The format of the track to which the data belongs. Null if the data does not belong to a + * specific track. + */ + @Nullable public final Format trackFormat; + /** + * One of the {@link C} {@code SELECTION_REASON_*} constants if the data belongs to a track. + * {@link C#SELECTION_REASON_UNKNOWN} otherwise. + */ + public final int trackSelectionReason; + /** + * Optional data associated with the selection of the track to which the data belongs. Null if + * the data does not belong to a track. + */ + @Nullable public final Object trackSelectionData; + /** + * The start time of the media, or {@link C#TIME_UNSET} if the data does not belong to a + * specific media period. + */ + public final long mediaStartTimeMs; + /** + * The end time of the media, or {@link C#TIME_UNSET} if the data does not belong to a specific + * media period or the end time is unknown. + */ + public final long mediaEndTimeMs; + + /** + * Creates media load data. + * + * @param dataType One of the {@link C} {@code DATA_TYPE_*} constants defining the type of data. + * @param trackType One of the {@link C} {@code TRACK_TYPE_*} constants if the data corresponds + * to media of a specific type. {@link C#TRACK_TYPE_UNKNOWN} otherwise. + * @param trackFormat The format of the track to which the data belongs. Null if the data does + * not belong to a track. + * @param trackSelectionReason One of the {@link C} {@code SELECTION_REASON_*} constants if the + * data belongs to a track. {@link C#SELECTION_REASON_UNKNOWN} otherwise. + * @param trackSelectionData Optional data associated with the selection of the track to which + * the data belongs. Null if the data does not belong to a track. + * @param mediaStartTimeMs The start time of the media, or {@link C#TIME_UNSET} if the data does + * not belong to a specific media period. + * @param mediaEndTimeMs The end time of the media, or {@link C#TIME_UNSET} if the data does not + * belong to a specific media period or the end time is unknown. + */ + public MediaLoadData( + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeMs, + long mediaEndTimeMs) { + this.dataType = dataType; + this.trackType = trackType; + this.trackFormat = trackFormat; + this.trackSelectionReason = trackSelectionReason; + this.trackSelectionData = trackSelectionData; + this.mediaStartTimeMs = mediaStartTimeMs; + this.mediaEndTimeMs = mediaEndTimeMs; + } + } + + /** + * Called when a media period is created by the media source. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the created media period. + */ + default void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when a media period is released by the media source. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the released media period. + */ + default void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when a load begins. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The value of {@link + * LoadEventInfo#uri} won't reflect potential redirection yet and {@link + * LoadEventInfo#responseHeaders} will be empty. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load ends. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load is canceled. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load error occurs. + * + * <p>The error may or may not have resulted in the load being canceled, as indicated by the + * {@code wasCanceled} parameter. If the load was canceled, {@link #onLoadCanceled} will + * <em>not</em> be called in addition to this method. + * + * <p>This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error and continue. Hence applications should + * <em>not</em> implement this method to display a user visible error or initiate an application + * level retry ({@link Player.EventListener#onPlayerError} is the appropriate place to implement + * such behavior). This method is called to provide the application with an opportunity to log the + * error if it wishes to do so. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + * @param error The load error. + * @param wasCanceled Whether the load was canceled as a result of the error. + */ + default void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) {} + + /** + * Called when a media period is first being read from. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the media period being read from. + */ + default void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when data is removed from the back of a media buffer, typically so that it can be + * re-buffered in a different format. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} the media belongs to. + * @param mediaLoadData The {@link MediaLoadData} defining the media being discarded. + */ + default void onUpstreamDiscarded( + int windowIndex, MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {} + + /** + * Called when a downstream format change occurs (i.e. when the format of the media being read + * from one or more {@link SampleStream}s provided by the source changes). + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} the media belongs to. + * @param mediaLoadData The {@link MediaLoadData} defining the newly selected downstream data. + */ + default void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {} + + /** Dispatches events to {@link MediaSourceEventListener}s. */ + final class EventDispatcher { + + /** The timeline window index reported with the events. */ + public final int windowIndex; + /** The {@link MediaPeriodId} reported with the events. */ + @Nullable public final MediaPeriodId mediaPeriodId; + + private final CopyOnWriteArrayList<ListenerAndHandler> listenerAndHandlers; + private final long mediaTimeOffsetMs; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + this( + /* listenerAndHandlers= */ new CopyOnWriteArrayList<>(), + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* mediaTimeOffsetMs= */ 0); + } + + private EventDispatcher( + CopyOnWriteArrayList<ListenerAndHandler> listenerAndHandlers, + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + long mediaTimeOffsetMs) { + this.listenerAndHandlers = listenerAndHandlers; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + this.mediaTimeOffsetMs = mediaTimeOffsetMs; + } + + /** + * Creates a view of the event dispatcher with pre-configured window index, media period id, and + * media time offset. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return A view of the event dispatcher with the pre-configured parameters. + */ + @CheckResult + public EventDispatcher withParameters( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + return new EventDispatcher( + listenerAndHandlers, windowIndex, mediaPeriodId, mediaTimeOffsetMs); + } + + /** + * Adds a listener to the event dispatcher. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + public void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + Assertions.checkArgument(handler != null && eventListener != null); + listenerAndHandlers.add(new ListenerAndHandler(handler, eventListener)); + } + + /** + * Removes a listener from the event dispatcher. + * + * @param eventListener The listener to be removed. + */ + public void removeEventListener(MediaSourceEventListener eventListener) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + if (listenerAndHandler.listener == eventListener) { + listenerAndHandlers.remove(listenerAndHandler); + } + } + } + + /** Dispatches {@link #onMediaPeriodCreated(int, MediaPeriodId)}. */ + public void mediaPeriodCreated() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onMediaPeriodCreated(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onMediaPeriodReleased(int, MediaPeriodId)}. */ + public void mediaPeriodReleased() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onMediaPeriodReleased(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted(DataSpec dataSpec, int dataType, long elapsedRealtimeMs) { + loadStarted( + dataSpec, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs); + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted( + DataSpec dataSpec, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs) { + loadStarted( + new LoadEventInfo( + dataSpec, + dataSpec.uri, + /* responseHeaders= */ Collections.emptyMap(), + elapsedRealtimeMs, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onLoadStarted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCompleted( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCompleted( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCompleted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCanceled( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCanceled( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCanceled(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded, + IOException error, + boolean wasCanceled) { + loadError( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + wasCanceled); + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded, + IOException error, + boolean wasCanceled) { + loadError( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs)), + error, + wasCanceled); + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadError( + windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData, error, wasCanceled)); + } + } + + /** Dispatches {@link #onReadingStarted(int, MediaPeriodId)}. */ + public void readingStarted() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onReadingStarted(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ + public void upstreamDiscarded(int trackType, long mediaStartTimeUs, long mediaEndTimeUs) { + upstreamDiscarded( + new MediaLoadData( + C.DATA_TYPE_MEDIA, + trackType, + /* trackFormat= */ null, + C.SELECTION_REASON_ADAPTIVE, + /* trackSelectionData= */ null, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ + public void upstreamDiscarded(MediaLoadData mediaLoadData) { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onUpstreamDiscarded(windowIndex, mediaPeriodId, mediaLoadData)); + } + } + + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ + public void downstreamFormatChanged( + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaTimeUs) { + downstreamFormatChanged( + new MediaLoadData( + C.DATA_TYPE_MEDIA, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaTimeUs), + /* mediaEndTimeMs= */ C.TIME_UNSET)); + } + + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ + public void downstreamFormatChanged(MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDownstreamFormatChanged(windowIndex, mediaPeriodId, mediaLoadData)); + } + } + + private long adjustMediaTime(long mediaTimeUs) { + long mediaTimeMs = C.usToMs(mediaTimeUs); + return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; + } + + private void postOrRun(Handler handler, Runnable runnable) { + if (handler.getLooper() == Looper.myLooper()) { + runnable.run(); + } else { + handler.post(runnable); + } + } + + private static final class ListenerAndHandler { + + public final Handler handler; + public final MediaSourceEventListener listener; + + public ListenerAndHandler(Handler handler, MediaSourceEventListener listener) { + this.handler = handler; + this.listener = listener; + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java new file mode 100644 index 0000000000..37c9dcee25 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import java.util.List; + +/** Factory for creating {@link MediaSource}s from URIs. */ +public interface MediaSourceFactory { + + /** + * Sets a list of {@link StreamKey StreamKeys} by which the manifest is filtered. + * + * @param streamKeys A list of {@link StreamKey StreamKeys}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + default MediaSourceFactory setStreamKeys(List<StreamKey> streamKeys) { + return this; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + MediaSourceFactory setDrmSessionManager(DrmSessionManager<?> drmSessionManager); + + /** + * Creates a new {@link MediaSource} with the specified {@code uri}. + * + * @param uri The URI to play. + * @return The new {@link MediaSource media source}. + */ + MediaSource createMediaSource(Uri uri); + + /** + * Returns the {@link C.ContentType content types} supported by media sources created by this + * factory. + */ + @C.ContentType + int[] getSupportedTypes(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java new file mode 100644 index 0000000000..f3315ec5cd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Merges multiple {@link MediaPeriod}s. + */ +/* package */ final class MergingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + public final MediaPeriod[] periods; + + private final IdentityHashMap<SampleStream, Integer> streamPeriodIndices; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final ArrayList<MediaPeriod> childrenPendingPreparation; + + @Nullable private Callback callback; + @Nullable private TrackGroupArray trackGroups; + private MediaPeriod[] enabledPeriods; + private SequenceableLoader compositeSequenceableLoader; + + public MergingMediaPeriod(CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + MediaPeriod... periods) { + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.periods = periods; + childrenPendingPreparation = new ArrayList<>(); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(); + streamPeriodIndices = new IdentityHashMap<>(); + enabledPeriods = new MediaPeriod[0]; + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + Collections.addAll(childrenPendingPreparation, periods); + for (MediaPeriod period : periods) { + period.prepare(this, positionUs); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + for (MediaPeriod period : periods) { + period.maybeThrowPrepareError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return Assertions.checkNotNull(trackGroups); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + // Map each selection and stream onto a child period index. + int[] streamChildIndices = new int[selections.length]; + int[] selectionChildIndices = new int[selections.length]; + for (int i = 0; i < selections.length; i++) { + streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET + : streamPeriodIndices.get(streams[i]); + selectionChildIndices[i] = C.INDEX_UNSET; + if (selections[i] != null) { + TrackGroup trackGroup = selections[i].getTrackGroup(); + for (int j = 0; j < periods.length; j++) { + if (periods[j].getTrackGroups().indexOf(trackGroup) != C.INDEX_UNSET) { + selectionChildIndices[i] = j; + break; + } + } + } + } + streamPeriodIndices.clear(); + // Select tracks for each child, copying the resulting streams back into a new streams array. + @NullableType SampleStream[] newStreams = new SampleStream[selections.length]; + @NullableType SampleStream[] childStreams = new SampleStream[selections.length]; + @NullableType TrackSelection[] childSelections = new TrackSelection[selections.length]; + ArrayList<MediaPeriod> enabledPeriodsList = new ArrayList<>(periods.length); + for (int i = 0; i < periods.length; i++) { + for (int j = 0; j < selections.length; j++) { + childStreams[j] = streamChildIndices[j] == i ? streams[j] : null; + childSelections[j] = selectionChildIndices[j] == i ? selections[j] : null; + } + long selectPositionUs = periods[i].selectTracks(childSelections, mayRetainStreamFlags, + childStreams, streamResetFlags, positionUs); + if (i == 0) { + positionUs = selectPositionUs; + } else if (selectPositionUs != positionUs) { + throw new IllegalStateException("Children enabled at different positions."); + } + boolean periodEnabled = false; + for (int j = 0; j < selections.length; j++) { + if (selectionChildIndices[j] == i) { + // Assert that the child provided a stream for the selection. + SampleStream childStream = Assertions.checkNotNull(childStreams[j]); + newStreams[j] = childStreams[j]; + periodEnabled = true; + streamPeriodIndices.put(childStream, i); + } else if (streamChildIndices[j] == i) { + // Assert that the child cleared any previous stream. + Assertions.checkState(childStreams[j] == null); + } + } + if (periodEnabled) { + enabledPeriodsList.add(periods[i]); + } + } + // Copy the new streams back into the streams array. + System.arraycopy(newStreams, 0, streams, 0, newStreams.length); + // Update the local state. + enabledPeriods = new MediaPeriod[enabledPeriodsList.size()]; + enabledPeriodsList.toArray(enabledPeriods); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(enabledPeriods); + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + for (MediaPeriod period : enabledPeriods) { + period.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + if (!childrenPendingPreparation.isEmpty()) { + // Preparation is still going on. + int childrenPendingPreparationSize = childrenPendingPreparation.size(); + for (int i = 0; i < childrenPendingPreparationSize; i++) { + childrenPendingPreparation.get(i).continueLoading(positionUs); + } + return false; + } else { + return compositeSequenceableLoader.continueLoading(positionUs); + } + } + + @Override + public boolean isLoading() { + return compositeSequenceableLoader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeSequenceableLoader.getNextLoadPositionUs(); + } + + @Override + public long readDiscontinuity() { + long positionUs = periods[0].readDiscontinuity(); + // Periods other than the first one are not allowed to report discontinuities. + for (int i = 1; i < periods.length; i++) { + if (periods[i].readDiscontinuity() != C.TIME_UNSET) { + throw new IllegalStateException("Child reported discontinuity."); + } + } + // It must be possible to seek enabled periods to the new position, if there is one. + if (positionUs != C.TIME_UNSET) { + for (MediaPeriod enabledPeriod : enabledPeriods) { + if (enabledPeriod != periods[0] + && enabledPeriod.seekToUs(positionUs) != positionUs) { + throw new IllegalStateException("Unexpected child seekToUs result."); + } + } + } + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + return compositeSequenceableLoader.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + positionUs = enabledPeriods[0].seekToUs(positionUs); + // Additional periods must seek to the same position. + for (int i = 1; i < enabledPeriods.length; i++) { + if (enabledPeriods[i].seekToUs(positionUs) != positionUs) { + throw new IllegalStateException("Unexpected child seekToUs result."); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + MediaPeriod queryPeriod = enabledPeriods.length > 0 ? enabledPeriods[0] : periods[0]; + return queryPeriod.getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + // MediaPeriod.Callback implementation + + @Override + public void onPrepared(MediaPeriod preparedPeriod) { + childrenPendingPreparation.remove(preparedPeriod); + if (!childrenPendingPreparation.isEmpty()) { + return; + } + int totalTrackGroupCount = 0; + for (MediaPeriod period : periods) { + totalTrackGroupCount += period.getTrackGroups().length; + } + TrackGroup[] trackGroupArray = new TrackGroup[totalTrackGroupCount]; + int trackGroupIndex = 0; + for (MediaPeriod period : periods) { + TrackGroupArray periodTrackGroups = period.getTrackGroups(); + int periodTrackGroupCount = periodTrackGroups.length; + for (int j = 0; j < periodTrackGroupCount; j++) { + trackGroupArray[trackGroupIndex++] = periodTrackGroups.get(j); + } + } + trackGroups = new TrackGroupArray(trackGroupArray); + Assertions.checkNotNull(callback).onPrepared(this); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod ignored) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java new file mode 100644 index 0000000000..ac2ef3c7da --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +/** + * Merges multiple {@link MediaSource}s. + * + * <p>The {@link Timeline}s of the sources being merged must have the same number of periods. + */ +public final class MergingMediaSource extends CompositeMediaSource<Integer> { + + /** + * Thrown when a {@link MergingMediaSource} cannot merge its sources. + */ + public static final class IllegalMergeException extends IOException { + + /** The reason the merge failed. One of {@link #REASON_PERIOD_COUNT_MISMATCH}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_PERIOD_COUNT_MISMATCH}) + public @interface Reason {} + /** + * The sources have different period counts. + */ + public static final int REASON_PERIOD_COUNT_MISMATCH = 0; + + /** + * The reason the merge failed. + */ + @Reason public final int reason; + + /** + * @param reason The reason the merge failed. + */ + public IllegalMergeException(@Reason int reason) { + this.reason = reason; + } + + } + + private static final int PERIOD_COUNT_UNSET = -1; + + private final MediaSource[] mediaSources; + private final Timeline[] timelines; + private final ArrayList<MediaSource> pendingTimelineSources; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + + private int periodCount; + @Nullable private IllegalMergeException mergeError; + + /** + * @param mediaSources The {@link MediaSource}s to merge. + */ + public MergingMediaSource(MediaSource... mediaSources) { + this(new DefaultCompositeSequenceableLoaderFactory(), mediaSources); + } + + /** + * @param compositeSequenceableLoaderFactory A factory to create composite + * {@link SequenceableLoader}s for when this media source loads data from multiple streams + * (video, audio etc...). + * @param mediaSources The {@link MediaSource}s to merge. + */ + public MergingMediaSource(CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + MediaSource... mediaSources) { + this.mediaSources = mediaSources; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + pendingTimelineSources = new ArrayList<>(Arrays.asList(mediaSources)); + periodCount = PERIOD_COUNT_UNSET; + timelines = new Timeline[mediaSources.length]; + } + + @Override + @Nullable + public Object getTag() { + return mediaSources.length > 0 ? mediaSources[0].getTag() : null; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + for (int i = 0; i < mediaSources.length; i++) { + prepareChildSource(i, mediaSources[i]); + } + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (mergeError != null) { + throw mergeError; + } + super.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + MediaPeriod[] periods = new MediaPeriod[mediaSources.length]; + int periodIndex = timelines[0].getIndexOfPeriod(id.periodUid); + for (int i = 0; i < periods.length; i++) { + MediaPeriodId childMediaPeriodId = + id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); + periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator, startPositionUs); + } + return new MergingMediaPeriod(compositeSequenceableLoaderFactory, periods); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MergingMediaPeriod mergingPeriod = (MergingMediaPeriod) mediaPeriod; + for (int i = 0; i < mediaSources.length; i++) { + mediaSources[i].releasePeriod(mergingPeriod.periods[i]); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + Arrays.fill(timelines, null); + periodCount = PERIOD_COUNT_UNSET; + mergeError = null; + pendingTimelineSources.clear(); + Collections.addAll(pendingTimelineSources, mediaSources); + } + + @Override + protected void onChildSourceInfoRefreshed( + Integer id, MediaSource mediaSource, Timeline timeline) { + if (mergeError == null) { + mergeError = checkTimelineMerges(timeline); + } + if (mergeError != null) { + return; + } + pendingTimelineSources.remove(mediaSource); + timelines[id] = timeline; + if (pendingTimelineSources.isEmpty()) { + refreshSourceInfo(timelines[0]); + } + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Integer id, MediaPeriodId mediaPeriodId) { + return id == 0 ? mediaPeriodId : null; + } + + @Nullable + private IllegalMergeException checkTimelineMerges(Timeline timeline) { + if (periodCount == PERIOD_COUNT_UNSET) { + periodCount = timeline.getPeriodCount(); + } else if (timeline.getPeriodCount() != periodCount) { + return new IllegalMergeException(IllegalMergeException.REASON_PERIOD_COUNT_MISMATCH); + } + return null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java new file mode 100644 index 0000000000..4c62a73edb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -0,0 +1,1162 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap.SeekPoints; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap.Unseekable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy.IcyHeaders; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ConditionVariable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** A {@link MediaPeriod} that extracts data using an {@link Extractor}. */ +/* package */ final class ProgressiveMediaPeriod + implements MediaPeriod, + ExtractorOutput, + Loader.Callback<ProgressiveMediaPeriod.ExtractingLoadable>, + Loader.ReleaseCallback, + UpstreamFormatChangedListener { + + /** + * Listener for information about the period. + */ + interface Listener { + + /** + * Called when the duration, the ability to seek within the period, or the categorization as + * live stream changes. + * + * @param durationUs The duration of the period, or {@link C#TIME_UNSET}. + * @param isSeekable Whether the period is seekable. + * @param isLive Whether the period is live. + */ + void onSourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive); + } + + /** + * When the source's duration is unknown, it is calculated by adding this value to the largest + * sample timestamp seen when buffering completes. + */ + private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10000; + + private static final Map<String, String> ICY_METADATA_HEADERS = createIcyMetadataHeaders(); + + private static final Format ICY_FORMAT = + Format.createSampleFormat("icy", MimeTypes.APPLICATION_ICY, Format.OFFSET_SAMPLE_RELATIVE); + + private final Uri uri; + private final DataSource dataSource; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final Listener listener; + private final Allocator allocator; + @Nullable private final String customCacheKey; + private final long continueLoadingCheckIntervalBytes; + private final Loader loader; + private final ExtractorHolder extractorHolder; + private final ConditionVariable loadCondition; + private final Runnable maybeFinishPrepareRunnable; + private final Runnable onContinueLoadingRequestedRunnable; + private final Handler handler; + + @Nullable private Callback callback; + @Nullable private SeekMap seekMap; + @Nullable private IcyHeaders icyHeaders; + private SampleQueue[] sampleQueues; + private TrackId[] sampleQueueTrackIds; + private boolean sampleQueuesBuilt; + private boolean prepared; + + @Nullable private PreparedState preparedState; + private boolean haveAudioVideoTracks; + private int dataType; + + private boolean seenFirstTrackSelection; + private boolean notifyDiscontinuity; + private boolean notifiedReadingStarted; + private int enabledTrackCount; + private long durationUs; + private long length; + private boolean isLive; + + private long lastSeekPositionUs; + private long pendingResetPositionUs; + private boolean pendingDeferredRetry; + + private int extractedSamplesCountAtStartOfLoad; + private boolean loadingFinished; + private boolean released; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSource The data source to read the media. + * @param extractors The extractors to use to read the data source. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + * @param listener A listener to notify when information about the period changes. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each + * invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}. + */ + // maybeFinishPrepare is not posted to the handler until initialization completes. + @SuppressWarnings({ + "nullness:argument.type.incompatible", + "nullness:methodref.receiver.bound.invalid" + }) + public ProgressiveMediaPeriod( + Uri uri, + DataSource dataSource, + Extractor[] extractors, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + Listener listener, + Allocator allocator, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes) { + this.uri = uri; + this.dataSource = dataSource; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.listener = listener; + this.allocator = allocator; + this.customCacheKey = customCacheKey; + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + loader = new Loader("Loader:ProgressiveMediaPeriod"); + extractorHolder = new ExtractorHolder(extractors); + loadCondition = new ConditionVariable(); + maybeFinishPrepareRunnable = this::maybeFinishPrepare; + onContinueLoadingRequestedRunnable = + () -> { + if (!released) { + Assertions.checkNotNull(callback) + .onContinueLoadingRequested(ProgressiveMediaPeriod.this); + } + }; + handler = new Handler(); + sampleQueueTrackIds = new TrackId[0]; + sampleQueues = new SampleQueue[0]; + pendingResetPositionUs = C.TIME_UNSET; + length = C.LENGTH_UNSET; + durationUs = C.TIME_UNSET; + dataType = C.DATA_TYPE_MEDIA; + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + if (prepared) { + // Discard as much as we can synchronously. We only do this if we're prepared, since otherwise + // sampleQueues may still be being modified by the loading thread. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.preRelease(); + } + } + loader.release(/* callback= */ this); + handler.removeCallbacksAndMessages(null); + callback = null; + released = true; + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void onLoaderReleased() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.release(); + } + extractorHolder.release(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + loadCondition.open(); + startLoading(); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + maybeThrowError(); + if (loadingFinished && !prepared) { + throw new ParserException("Loading finished before preparation is complete."); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return getPreparedState().tracks; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + PreparedState preparedState = getPreparedState(); + TrackGroupArray tracks = preparedState.tracks; + boolean[] trackEnabledStates = preparedState.trackEnabledStates; + int oldEnabledTrackCount = enabledTrackCount; + // Deselect old tracks. + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + int track = ((SampleStreamImpl) streams[i]).track; + Assertions.checkState(trackEnabledStates[track]); + enabledTrackCount--; + trackEnabledStates[track] = false; + streams[i] = null; + } + } + // We'll always need to seek if this is a first selection to a non-zero position, or if we're + // making a selection having previously disabled all tracks. + boolean seekRequired = seenFirstTrackSelection ? oldEnabledTrackCount == 0 : positionUs != 0; + // Select new tracks. + for (int i = 0; i < selections.length; i++) { + if (streams[i] == null && selections[i] != null) { + TrackSelection selection = selections[i]; + Assertions.checkState(selection.length() == 1); + Assertions.checkState(selection.getIndexInTrackGroup(0) == 0); + int track = tracks.indexOf(selection.getTrackGroup()); + Assertions.checkState(!trackEnabledStates[track]); + enabledTrackCount++; + trackEnabledStates[track] = true; + streams[i] = new SampleStreamImpl(track); + streamResetFlags[i] = true; + // If there's still a chance of avoiding a seek, try and seek within the sample queue. + if (!seekRequired) { + SampleQueue sampleQueue = sampleQueues[track]; + // A seek can be avoided if we're able to seek to the current playback position in the + // sample queue, or if we haven't read anything from the queue since the previous seek + // (this case is common for sparse tracks such as metadata tracks). In all other cases a + // seek is required. + seekRequired = + !sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true) + && sampleQueue.getReadIndex() != 0; + } + } + } + if (enabledTrackCount == 0) { + pendingDeferredRetry = false; + notifyDiscontinuity = false; + if (loader.isLoading()) { + // Discard as much as we can synchronously. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.discardToEnd(); + } + loader.cancelLoading(); + } else { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + } + } else if (seekRequired) { + positionUs = seekToUs(positionUs); + // We'll need to reset renderers consuming from all streams due to the seek. + for (int i = 0; i < streams.length; i++) { + if (streams[i] != null) { + streamResetFlags[i] = true; + } + } + } + seenFirstTrackSelection = true; + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (isPendingReset()) { + return; + } + boolean[] trackEnabledStates = getPreparedState().trackEnabledStates; + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + sampleQueues[i].discardTo(positionUs, toKeyframe, trackEnabledStates[i]); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + @Override + public boolean continueLoading(long playbackPositionUs) { + if (loadingFinished + || loader.hasFatalError() + || pendingDeferredRetry + || (prepared && enabledTrackCount == 0)) { + return false; + } + boolean continuedLoading = loadCondition.open(); + if (!loader.isLoading()) { + startLoading(); + continuedLoading = true; + } + return continuedLoading; + } + + @Override + public boolean isLoading() { + return loader.isLoading() && loadCondition.isOpen(); + } + + @Override + public long getNextLoadPositionUs() { + return enabledTrackCount == 0 ? C.TIME_END_OF_SOURCE : getBufferedPositionUs(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + if (notifyDiscontinuity + && (loadingFinished || getExtractedSamplesCount() > extractedSamplesCountAtStartOfLoad)) { + notifyDiscontinuity = false; + return lastSeekPositionUs; + } + return C.TIME_UNSET; + } + + @Override + public long getBufferedPositionUs() { + boolean[] trackIsAudioVideoFlags = getPreparedState().trackIsAudioVideoFlags; + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } + long largestQueuedTimestampUs = Long.MAX_VALUE; + if (haveAudioVideoTracks) { + // Ignore non-AV tracks, which may be sparse or poorly interleaved. + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + if (trackIsAudioVideoFlags[i] && !sampleQueues[i].isLastSampleQueued()) { + largestQueuedTimestampUs = Math.min(largestQueuedTimestampUs, + sampleQueues[i].getLargestQueuedTimestampUs()); + } + } + } + if (largestQueuedTimestampUs == Long.MAX_VALUE) { + largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + } + return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs + : largestQueuedTimestampUs; + } + + @Override + public long seekToUs(long positionUs) { + PreparedState preparedState = getPreparedState(); + SeekMap seekMap = preparedState.seekMap; + boolean[] trackIsAudioVideoFlags = preparedState.trackIsAudioVideoFlags; + // Treat all seeks into non-seekable media as being to t=0. + positionUs = seekMap.isSeekable() ? positionUs : 0; + + notifyDiscontinuity = false; + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return positionUs; + } + + // If we're not playing a live stream, try and seek within the buffer. + if (dataType != C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE + && seekInsideBufferUs(trackIsAudioVideoFlags, positionUs)) { + return positionUs; + } + + // We can't seek inside the buffer, and so need to reset. + pendingDeferredRetry = false; + pendingResetPositionUs = positionUs; + loadingFinished = false; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + SeekMap seekMap = getPreparedState().seekMap; + if (!seekMap.isSeekable()) { + // Treat all seeks into non-seekable media as being to t=0. + return 0; + } + SeekPoints seekPoints = seekMap.getSeekPoints(positionUs); + return Util.resolveSeekPositionUs( + positionUs, seekParameters, seekPoints.first.timeUs, seekPoints.second.timeUs); + } + + // SampleStream methods. + + /* package */ boolean isReady(int track) { + return !suppressRead() && sampleQueues[track].isReady(loadingFinished); + } + + /* package */ void maybeThrowError(int sampleQueueIndex) throws IOException { + sampleQueues[sampleQueueIndex].maybeThrowError(); + maybeThrowError(); + } + + /* package */ void maybeThrowError() throws IOException { + loader.maybeThrowError(loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); + } + + /* package */ int readData( + int sampleQueueIndex, + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired) { + if (suppressRead()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyDownstreamFormat(sampleQueueIndex); + int result = + sampleQueues[sampleQueueIndex].read( + formatHolder, buffer, formatRequired, loadingFinished, lastSeekPositionUs); + if (result == C.RESULT_NOTHING_READ) { + maybeStartDeferredRetry(sampleQueueIndex); + } + return result; + } + + /* package */ int skipData(int track, long positionUs) { + if (suppressRead()) { + return 0; + } + maybeNotifyDownstreamFormat(track); + SampleQueue sampleQueue = sampleQueues[track]; + int skipCount; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + skipCount = sampleQueue.advanceToEnd(); + } else { + skipCount = sampleQueue.advanceTo(positionUs); + } + if (skipCount == 0) { + maybeStartDeferredRetry(track); + } + return skipCount; + } + + private void maybeNotifyDownstreamFormat(int track) { + PreparedState preparedState = getPreparedState(); + boolean[] trackNotifiedDownstreamFormats = preparedState.trackNotifiedDownstreamFormats; + if (!trackNotifiedDownstreamFormats[track]) { + Format trackFormat = preparedState.tracks.get(track).getFormat(/* index= */ 0); + eventDispatcher.downstreamFormatChanged( + MimeTypes.getTrackType(trackFormat.sampleMimeType), + trackFormat, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + lastSeekPositionUs); + trackNotifiedDownstreamFormats[track] = true; + } + } + + private void maybeStartDeferredRetry(int track) { + boolean[] trackIsAudioVideoFlags = getPreparedState().trackIsAudioVideoFlags; + if (!pendingDeferredRetry + || !trackIsAudioVideoFlags[track] + || sampleQueues[track].isReady(/* loadingFinished= */ false)) { + return; + } + pendingResetPositionUs = 0; + pendingDeferredRetry = false; + notifyDiscontinuity = true; + lastSeekPositionUs = 0; + extractedSamplesCountAtStartOfLoad = 0; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + private boolean suppressRead() { + return notifyDiscontinuity || isPendingReset(); + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ExtractingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + if (durationUs == C.TIME_UNSET && seekMap != null) { + boolean isSeekable = seekMap.isSeekable(); + long largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + durationUs = largestQueuedTimestampUs == Long.MIN_VALUE ? 0 + : largestQueuedTimestampUs + DEFAULT_LAST_SAMPLE_DURATION_US; + listener.onSourceInfoRefreshed(durationUs, isSeekable, isLive); + } + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + copyLengthFromLoader(loadable); + loadingFinished = true; + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + @Override + public void onLoadCanceled(ExtractingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + if (!released) { + copyLengthFromLoader(loadable); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + if (enabledTrackCount > 0) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + } + } + + @Override + public LoadErrorAction onLoadError( + ExtractingLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + copyLengthFromLoader(loadable); + LoadErrorAction loadErrorAction; + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor(dataType, loadDurationMs, error, errorCount); + if (retryDelayMs == C.TIME_UNSET) { + loadErrorAction = Loader.DONT_RETRY_FATAL; + } else /* the load should be retried */ { + int extractedSamplesCount = getExtractedSamplesCount(); + boolean madeProgress = extractedSamplesCount > extractedSamplesCountAtStartOfLoad; + loadErrorAction = + configureRetry(loadable, extractedSamplesCount) + ? Loader.createRetryAction(/* resetErrorCount= */ madeProgress, retryDelayMs) + : Loader.DONT_RETRY; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead(), + error, + !loadErrorAction.isRetry()); + return loadErrorAction; + } + + // ExtractorOutput implementation. Called by the loading thread. + + @Override + public TrackOutput track(int id, int type) { + return prepareTrackOutput(new TrackId(id, /* isIcyTrack= */ false)); + } + + @Override + public void endTracks() { + sampleQueuesBuilt = true; + handler.post(maybeFinishPrepareRunnable); + } + + @Override + public void seekMap(SeekMap seekMap) { + this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs */ C.TIME_UNSET); + handler.post(maybeFinishPrepareRunnable); + } + + // Icy metadata. Called by the loading thread. + + /* package */ TrackOutput icyTrack() { + return prepareTrackOutput(new TrackId(0, /* isIcyTrack= */ true)); + } + + // UpstreamFormatChangedListener implementation. Called by the loading thread. + + @Override + public void onUpstreamFormatChanged(Format format) { + handler.post(maybeFinishPrepareRunnable); + } + + // Internal methods. + + private TrackOutput prepareTrackOutput(TrackId id) { + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + if (id.equals(sampleQueueTrackIds[i])) { + return sampleQueues[i]; + } + } + SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager); + trackOutput.setUpstreamFormatChangeListener(this); + @NullableType + TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); + sampleQueueTrackIds[trackCount] = id; + this.sampleQueueTrackIds = Util.castNonNullTypeArray(sampleQueueTrackIds); + @NullableType SampleQueue[] sampleQueues = Arrays.copyOf(this.sampleQueues, trackCount + 1); + sampleQueues[trackCount] = trackOutput; + this.sampleQueues = Util.castNonNullTypeArray(sampleQueues); + return trackOutput; + } + + private void maybeFinishPrepare() { + SeekMap seekMap = this.seekMap; + if (released || prepared || !sampleQueuesBuilt || seekMap == null) { + return; + } + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue.getUpstreamFormat() == null) { + return; + } + } + loadCondition.close(); + int trackCount = sampleQueues.length; + TrackGroup[] trackArray = new TrackGroup[trackCount]; + boolean[] trackIsAudioVideoFlags = new boolean[trackCount]; + durationUs = seekMap.getDurationUs(); + for (int i = 0; i < trackCount; i++) { + Format trackFormat = sampleQueues[i].getUpstreamFormat(); + String mimeType = trackFormat.sampleMimeType; + boolean isAudio = MimeTypes.isAudio(mimeType); + boolean isAudioVideo = isAudio || MimeTypes.isVideo(mimeType); + trackIsAudioVideoFlags[i] = isAudioVideo; + haveAudioVideoTracks |= isAudioVideo; + IcyHeaders icyHeaders = this.icyHeaders; + if (icyHeaders != null) { + if (isAudio || sampleQueueTrackIds[i].isIcyTrack) { + Metadata metadata = trackFormat.metadata; + trackFormat = + trackFormat.copyWithMetadata( + metadata == null + ? new Metadata(icyHeaders) + : metadata.copyWithAppendedEntries(icyHeaders)); + } + if (isAudio + && trackFormat.bitrate == Format.NO_VALUE + && icyHeaders.bitrate != Format.NO_VALUE) { + trackFormat = trackFormat.copyWithBitrate(icyHeaders.bitrate); + } + } + trackArray[i] = new TrackGroup(trackFormat); + } + isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; + dataType = isLive ? C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE : C.DATA_TYPE_MEDIA; + preparedState = + new PreparedState(seekMap, new TrackGroupArray(trackArray), trackIsAudioVideoFlags); + prepared = true; + listener.onSourceInfoRefreshed(durationUs, seekMap.isSeekable(), isLive); + Assertions.checkNotNull(callback).onPrepared(this); + } + + private PreparedState getPreparedState() { + return Assertions.checkNotNull(preparedState); + } + + private void copyLengthFromLoader(ExtractingLoadable loadable) { + if (length == C.LENGTH_UNSET) { + length = loadable.length; + } + } + + private void startLoading() { + ExtractingLoadable loadable = + new ExtractingLoadable( + uri, dataSource, extractorHolder, /* extractorOutput= */ this, loadCondition); + if (prepared) { + SeekMap seekMap = getPreparedState().seekMap; + Assertions.checkState(isPendingReset()); + if (durationUs != C.TIME_UNSET && pendingResetPositionUs > durationUs) { + loadingFinished = true; + pendingResetPositionUs = C.TIME_UNSET; + return; + } + loadable.setLoadPosition( + seekMap.getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); + pendingResetPositionUs = C.TIME_UNSET; + } + extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); + eventDispatcher.loadStarted( + loadable.dataSpec, + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs); + } + + /** + * Called to configure a retry when a load error occurs. + * + * @param loadable The current loadable for which the error was encountered. + * @param currentExtractedSampleCount The current number of samples that have been extracted into + * the sample queues. + * @return Whether the loader should retry with the current loadable. False indicates a deferred + * retry. + */ + private boolean configureRetry(ExtractingLoadable loadable, int currentExtractedSampleCount) { + if (length != C.LENGTH_UNSET + || (seekMap != null && seekMap.getDurationUs() != C.TIME_UNSET)) { + // We're playing an on-demand stream. Resume the current loadable, which will + // request data starting from the point it left off. + extractedSamplesCountAtStartOfLoad = currentExtractedSampleCount; + return true; + } else if (prepared && !suppressRead()) { + // We're playing a stream of unknown length and duration. Assume it's live, and therefore that + // the data at the uri is a continuously shifting window of the latest available media. For + // this case there's no way to continue loading from where a previous load finished, so it's + // necessary to load from the start whenever commencing a new load. Deferring the retry until + // we run out of buffered data makes for a much better user experience. See: + // https://github.com/google/ExoPlayer/issues/1606. + // Note that the suppressRead() check means only a single deferred retry can occur without + // progress being made. Any subsequent failures without progress will go through the else + // block below. + pendingDeferredRetry = true; + return false; + } else { + // This is the same case as above, except in this case there's no value in deferring the retry + // because there's no buffered data to be read. This case also covers an on-demand stream with + // unknown length that has yet to be prepared. This case cannot be disambiguated from the live + // stream case, so we have no option but to load from the start. + notifyDiscontinuity = prepared; + lastSeekPositionUs = 0; + extractedSamplesCountAtStartOfLoad = 0; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + loadable.setLoadPosition(0, 0); + return true; + } + } + + /** + * Attempts to seek to the specified position within the sample queues. + * + * @param trackIsAudioVideoFlags Whether each track is audio/video. + * @param positionUs The seek position in microseconds. + * @return Whether the in-buffer seek was successful. + */ + private boolean seekInsideBufferUs(boolean[] trackIsAudioVideoFlags, long positionUs) { + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + SampleQueue sampleQueue = sampleQueues[i]; + boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false); + // If we have AV tracks then an in-buffer seek is successful if the seek into every AV queue + // is successful. We ignore whether seeks within non-AV queues are successful in this case, as + // they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is + // successful only if the seek into every queue succeeds. + if (!seekInsideQueue && (trackIsAudioVideoFlags[i] || !haveAudioVideoTracks)) { + return false; + } + } + return true; + } + + private int getExtractedSamplesCount() { + int extractedSamplesCount = 0; + for (SampleQueue sampleQueue : sampleQueues) { + extractedSamplesCount += sampleQueue.getWriteIndex(); + } + return extractedSamplesCount; + } + + private long getLargestQueuedTimestampUs() { + long largestQueuedTimestampUs = Long.MIN_VALUE; + for (SampleQueue sampleQueue : sampleQueues) { + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, + sampleQueue.getLargestQueuedTimestampUs()); + } + return largestQueuedTimestampUs; + } + + private boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + private final class SampleStreamImpl implements SampleStream { + + private final int track; + + public SampleStreamImpl(int track) { + this.track = track; + } + + @Override + public boolean isReady() { + return ProgressiveMediaPeriod.this.isReady(track); + } + + @Override + public void maybeThrowError() throws IOException { + ProgressiveMediaPeriod.this.maybeThrowError(track); + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + return ProgressiveMediaPeriod.this.readData(track, formatHolder, buffer, formatRequired); + } + + @Override + public int skipData(long positionUs) { + return ProgressiveMediaPeriod.this.skipData(track, positionUs); + } + + } + + /** Loads the media stream and extracts sample data from it. */ + /* package */ final class ExtractingLoadable implements Loadable, IcyDataSource.Listener { + + private final Uri uri; + private final StatsDataSource dataSource; + private final ExtractorHolder extractorHolder; + private final ExtractorOutput extractorOutput; + private final ConditionVariable loadCondition; + private final PositionHolder positionHolder; + + private volatile boolean loadCanceled; + + private boolean pendingExtractorSeek; + private long seekTimeUs; + private DataSpec dataSpec; + private long length; + @Nullable private TrackOutput icyTrackOutput; + private boolean seenIcyMetadata; + + @SuppressWarnings("method.invocation.invalid") + public ExtractingLoadable( + Uri uri, + DataSource dataSource, + ExtractorHolder extractorHolder, + ExtractorOutput extractorOutput, + ConditionVariable loadCondition) { + this.uri = uri; + this.dataSource = new StatsDataSource(dataSource); + this.extractorHolder = extractorHolder; + this.extractorOutput = extractorOutput; + this.loadCondition = loadCondition; + this.positionHolder = new PositionHolder(); + this.pendingExtractorSeek = true; + this.length = C.LENGTH_UNSET; + dataSpec = buildDataSpec(/* position= */ 0); + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public void load() throws IOException, InterruptedException { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + ExtractorInput input = null; + try { + long position = positionHolder.position; + dataSpec = buildDataSpec(position); + length = dataSource.open(dataSpec); + if (length != C.LENGTH_UNSET) { + length += position; + } + Uri uri = Assertions.checkNotNull(dataSource.getUri()); + icyHeaders = IcyHeaders.parse(dataSource.getResponseHeaders()); + DataSource extractorDataSource = dataSource; + if (icyHeaders != null && icyHeaders.metadataInterval != C.LENGTH_UNSET) { + extractorDataSource = new IcyDataSource(dataSource, icyHeaders.metadataInterval, this); + icyTrackOutput = icyTrack(); + icyTrackOutput.format(ICY_FORMAT); + } + input = new DefaultExtractorInput(extractorDataSource, position, length); + Extractor extractor = extractorHolder.selectExtractor(input, extractorOutput, uri); + + // MP3 live streams commonly have seekable metadata, despite being unseekable. + if (icyHeaders != null && extractor instanceof Mp3Extractor) { + ((Mp3Extractor) extractor).disableSeeking(); + } + + if (pendingExtractorSeek) { + extractor.seek(position, seekTimeUs); + pendingExtractorSeek = false; + } + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + loadCondition.block(); + result = extractor.read(input, positionHolder); + if (input.getPosition() > position + continueLoadingCheckIntervalBytes) { + position = input.getPosition(); + loadCondition.close(); + handler.post(onContinueLoadingRequestedRunnable); + } + } + } finally { + if (result == Extractor.RESULT_SEEK) { + result = Extractor.RESULT_CONTINUE; + } else if (input != null) { + positionHolder.position = input.getPosition(); + } + Util.closeQuietly(dataSource); + } + } + } + + // IcyDataSource.Listener + + @Override + public void onIcyMetadata(ParsableByteArray metadata) { + // Always output the first ICY metadata at the start time. This helps minimize any delay + // between the start of playback and the first ICY metadata event. + long timeUs = + !seenIcyMetadata ? seekTimeUs : Math.max(getLargestQueuedTimestampUs(), seekTimeUs); + int length = metadata.bytesLeft(); + TrackOutput icyTrackOutput = Assertions.checkNotNull(this.icyTrackOutput); + icyTrackOutput.sampleData(metadata, length); + icyTrackOutput.sampleMetadata( + timeUs, C.BUFFER_FLAG_KEY_FRAME, length, /* offset= */ 0, /* encryptionData= */ null); + seenIcyMetadata = true; + } + + // Internal methods. + + private DataSpec buildDataSpec(long position) { + // Disable caching if the content length cannot be resolved, since this is indicative of a + // progressive live stream. + return new DataSpec( + uri, + position, + C.LENGTH_UNSET, + customCacheKey, + DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, + ICY_METADATA_HEADERS); + } + + private void setLoadPosition(long position, long timeUs) { + positionHolder.position = position; + seekTimeUs = timeUs; + pendingExtractorSeek = true; + seenIcyMetadata = false; + } + } + + /** Stores a list of extractors and a selected extractor when the format has been detected. */ + private static final class ExtractorHolder { + + private final Extractor[] extractors; + + @Nullable private Extractor extractor; + + /** + * Creates a holder that will select an extractor and initialize it using the specified output. + * + * @param extractors One or more extractors to choose from. + */ + public ExtractorHolder(Extractor[] extractors) { + this.extractors = extractors; + } + + /** + * Returns an initialized extractor for reading {@code input}, and returns the same extractor on + * later calls. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param output The {@link ExtractorOutput} that will be used to initialize the selected + * extractor. + * @param uri The {@link Uri} of the data. + * @return An initialized extractor for reading {@code input}. + * @throws UnrecognizedInputFormatException Thrown if the input format could not be detected. + * @throws IOException Thrown if the input could not be read. + * @throws InterruptedException Thrown if the thread was interrupted. + */ + public Extractor selectExtractor(ExtractorInput input, ExtractorOutput output, Uri uri) + throws IOException, InterruptedException { + if (extractor != null) { + return extractor; + } + if (extractors.length == 1) { + this.extractor = extractors[0]; + } else { + for (Extractor extractor : extractors) { + try { + if (extractor.sniff(input)) { + this.extractor = extractor; + break; + } + } catch (EOFException e) { + // Do nothing. + } finally { + input.resetPeekPosition(); + } + } + if (extractor == null) { + throw new UnrecognizedInputFormatException( + "None of the available extractors (" + + Util.getCommaDelimitedSimpleClassNames(extractors) + + ") could read the stream.", + uri); + } + } + extractor.init(output); + return extractor; + } + + public void release() { + if (extractor != null) { + extractor.release(); + extractor = null; + } + } + } + + /** Stores state that is initialized when preparation completes. */ + private static final class PreparedState { + + public final SeekMap seekMap; + public final TrackGroupArray tracks; + public final boolean[] trackIsAudioVideoFlags; + public final boolean[] trackEnabledStates; + public final boolean[] trackNotifiedDownstreamFormats; + + public PreparedState( + SeekMap seekMap, TrackGroupArray tracks, boolean[] trackIsAudioVideoFlags) { + this.seekMap = seekMap; + this.tracks = tracks; + this.trackIsAudioVideoFlags = trackIsAudioVideoFlags; + this.trackEnabledStates = new boolean[tracks.length]; + this.trackNotifiedDownstreamFormats = new boolean[tracks.length]; + } + } + + /** Identifies a track. */ + private static final class TrackId { + + public final int id; + public final boolean isIcyTrack; + + public TrackId(int id, boolean isIcyTrack) { + this.id = id; + this.isIcyTrack = isIcyTrack; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackId other = (TrackId) obj; + return id == other.id && isIcyTrack == other.isIcyTrack; + } + + @Override + public int hashCode() { + return 31 * id + (isIcyTrack ? 1 : 0); + } + } + + private static Map<String, String> createIcyMetadataHeaders() { + Map<String, String> headers = new HashMap<>(); + headers.put( + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME, + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE); + return Collections.unmodifiableMap(headers); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java new file mode 100644 index 0000000000..bed34a354b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * Provides one period that loads data from a {@link Uri} and extracted using an {@link Extractor}. + * + * <p>If the possible input stream container formats are known, pass a factory that instantiates + * extractors for them to the constructor. Otherwise, pass a {@link DefaultExtractorsFactory} to use + * the default extractors. When reading a new stream, the first {@link Extractor} in the array of + * extractors created by the factory that returns {@code true} from {@link Extractor#sniff} will be + * used to extract samples from the input stream. + * + * <p>Note that the built-in extractor for FLV streams does not support seeking. + */ +public final class ProgressiveMediaSource extends BaseMediaSource + implements ProgressiveMediaPeriod.Listener { + + /** Factory for {@link ProgressiveMediaSource}s. */ + public static final class Factory implements MediaSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + private ExtractorsFactory extractorsFactory; + @Nullable private String customCacheKey; + @Nullable private Object tag; + private DrmSessionManager<?> drmSessionManager; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private int continueLoadingCheckIntervalBytes; + private boolean isCreateCalled; + + /** + * Creates a new factory for {@link ProgressiveMediaSource}s, using the extractors provided by + * {@link DefaultExtractorsFactory}. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this(dataSourceFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a new factory for {@link ProgressiveMediaSource}s. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for extractors used to extract media from its container. + */ + public Factory(DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { + this.dataSourceFactory = dataSourceFactory; + this.extractorsFactory = extractorsFactory; + drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + + /** + * Sets the factory for {@link Extractor}s to process the media stream. The default value is an + * instance of {@link DefaultExtractorsFactory}. + * + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those + * formats. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + * @deprecated Pass the {@link ExtractorsFactory} via {@link #Factory(DataSource.Factory, + * ExtractorsFactory)}. This is necessary so that proguard can treat the default extractors + * factory as unused. + */ + @Deprecated + public Factory setExtractorsFactory(ExtractorsFactory extractorsFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorsFactory = extractorsFactory; + return this; + } + + /** + * Sets the custom key that uniquely identifies the original stream. Used for cache indexing. + * The default value is {@code null}. + * + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for + * cache indexing. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setCustomCacheKey(@Nullable String customCacheKey) { + Assertions.checkState(!isCreateCalled); + this.customCacheKey = customCacheKey; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the number of bytes that should be loaded between each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. The default value is + * {@link #DEFAULT_LOADING_CHECK_INTERVAL_BYTES}. + * + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between + * each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckIntervalBytes) { + Assertions.checkState(!isCreateCalled); + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + return this; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + + /** + * Returns a new {@link ProgressiveMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @return The new {@link ProgressiveMediaSource}. + */ + @Override + public ProgressiveMediaSource createMediaSource(Uri uri) { + isCreateCalled = true; + return new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + drmSessionManager, + loadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_OTHER}; + } + } + + /** + * The default number of bytes that should be loaded between each each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + */ + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = 1024 * 1024; + + private final Uri uri; + private final DataSource.Factory dataSourceFactory; + private final ExtractorsFactory extractorsFactory; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; + @Nullable private final String customCacheKey; + private final int continueLoadingCheckIntervalBytes; + @Nullable private final Object tag; + + private long timelineDurationUs; + private boolean timelineIsSeekable; + private boolean timelineIsLive; + @Nullable private TransferListener transferListener; + + // TODO: Make private when ExtractorMediaSource is deleted. + /* package */ ProgressiveMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes, + @Nullable Object tag) { + this.uri = uri; + this.dataSourceFactory = dataSourceFactory; + this.extractorsFactory = extractorsFactory; + this.drmSessionManager = drmSessionManager; + this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; + this.customCacheKey = customCacheKey; + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + this.timelineDurationUs = C.TIME_UNSET; + this.tag = tag; + } + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + transferListener = mediaTransferListener; + drmSessionManager.prepare(); + notifySourceInfoRefreshed(timelineDurationUs, timelineIsSeekable, timelineIsLive); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return new ProgressiveMediaPeriod( + uri, + dataSource, + extractorsFactory.createExtractors(), + drmSessionManager, + loadableLoadErrorHandlingPolicy, + createEventDispatcher(id), + this, + allocator, + customCacheKey, + continueLoadingCheckIntervalBytes); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((ProgressiveMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + drmSessionManager.release(); + } + + // ProgressiveMediaPeriod.Listener implementation. + + @Override + public void onSourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive) { + // If we already have the duration from a previous source info refresh, use it. + durationUs = durationUs == C.TIME_UNSET ? timelineDurationUs : durationUs; + if (timelineDurationUs == durationUs + && timelineIsSeekable == isSeekable + && timelineIsLive == isLive) { + // Suppress no-op source info changes. + return; + } + notifySourceInfoRefreshed(durationUs, isSeekable, isLive); + } + + // Internal methods. + + private void notifySourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive) { + timelineDurationUs = durationUs; + timelineIsSeekable = isSeekable; + timelineIsLive = isLive; + // TODO: Split up isDynamic into multiple fields to indicate which values may change. Then + // indicate that the duration may change until it's known. See [internal: b/69703223]. + refreshSourceInfo( + new SinglePeriodTimeline( + timelineDurationUs, + timelineIsSeekable, + /* isDynamic= */ false, + /* isLive= */ timelineIsLive, + /* manifest= */ null, + tag)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java new file mode 100644 index 0000000000..81933a468d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.CryptoInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.SampleExtrasHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocation; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** A queue of media sample data. */ +/* package */ class SampleDataQueue { + + private static final int INITIAL_SCRATCH_SIZE = 32; + + private final Allocator allocator; + private final int allocationLength; + private final ParsableByteArray scratch; + + // References into the linked list of allocations. + private AllocationNode firstAllocationNode; + private AllocationNode readAllocationNode; + private AllocationNode writeAllocationNode; + + // Accessed only by the loading thread (or the consuming thread when there is no loading thread). + private long totalBytesWritten; + + public SampleDataQueue(Allocator allocator) { + this.allocator = allocator; + allocationLength = allocator.getIndividualAllocationLength(); + scratch = new ParsableByteArray(INITIAL_SCRATCH_SIZE); + firstAllocationNode = new AllocationNode(/* startPosition= */ 0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** Clears all sample data. */ + public void reset() { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + totalBytesWritten = 0; + allocator.trim(); + } + + /** + * Discards sample data bytes from the write side of the queue. + * + * @param totalBytesWritten The reduced total number of bytes written after the samples have been + * discarded, or 0 if the queue is now empty. + */ + public void discardUpstreamSampleBytes(long totalBytesWritten) { + this.totalBytesWritten = totalBytesWritten; + if (this.totalBytesWritten == 0 + || this.totalBytesWritten == firstAllocationNode.startPosition) { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(this.totalBytesWritten, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } else { + // Find the last node containing at least 1 byte of data that we need to keep. + AllocationNode lastNodeToKeep = firstAllocationNode; + while (this.totalBytesWritten > lastNodeToKeep.endPosition) { + lastNodeToKeep = lastNodeToKeep.next; + } + // Discard all subsequent nodes. + AllocationNode firstNodeToDiscard = lastNodeToKeep.next; + clearAllocationNodes(firstNodeToDiscard); + // Reset the successor of the last node to be an uninitialized node. + lastNodeToKeep.next = new AllocationNode(lastNodeToKeep.endPosition, allocationLength); + // Update writeAllocationNode and readAllocationNode as necessary. + writeAllocationNode = + this.totalBytesWritten == lastNodeToKeep.endPosition + ? lastNodeToKeep.next + : lastNodeToKeep; + if (readAllocationNode == firstNodeToDiscard) { + readAllocationNode = lastNodeToKeep.next; + } + } + } + + // Called by the consuming thread. + + /** Rewinds the read position to the first sample in the queue. */ + public void rewind() { + readAllocationNode = firstAllocationNode; + } + + /** + * Reads data from the rolling buffer to populate a decoder input buffer. + * + * @param buffer The buffer to populate. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + public void readToBuffer(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + // Read encryption data if the sample is encrypted. + if (buffer.isEncrypted()) { + readEncryptionData(buffer, extrasHolder); + } + // Read sample data, extracting supplemental data into a separate buffer if needed. + if (buffer.hasSupplementalData()) { + // If there is supplemental data, the sample data is prefixed by its size. + scratch.reset(4); + readData(extrasHolder.offset, scratch.data, 4); + int sampleSize = scratch.readUnsignedIntToInt(); + extrasHolder.offset += 4; + extrasHolder.size -= 4; + + // Write the sample data. + buffer.ensureSpaceForWrite(sampleSize); + readData(extrasHolder.offset, buffer.data, sampleSize); + extrasHolder.offset += sampleSize; + extrasHolder.size -= sampleSize; + + // Write the remaining data as supplemental data. + buffer.resetSupplementalData(extrasHolder.size); + readData(extrasHolder.offset, buffer.supplementalData, extrasHolder.size); + } else { + // Write the sample data. + buffer.ensureSpaceForWrite(extrasHolder.size); + readData(extrasHolder.offset, buffer.data, extrasHolder.size); + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The new absolute read position. May be {@link C#POSITION_UNSET}, in + * which case calling this method is a no-op. + */ + public void discardDownstreamTo(long absolutePosition) { + if (absolutePosition == C.POSITION_UNSET) { + return; + } + while (absolutePosition >= firstAllocationNode.endPosition) { + // Advance firstAllocationNode to the specified absolute position. Also clear nodes that are + // advanced past, and return their underlying allocations to the allocator. + allocator.release(firstAllocationNode.allocation); + firstAllocationNode = firstAllocationNode.clear(); + } + if (readAllocationNode.startPosition < firstAllocationNode.startPosition) { + // We discarded the node referenced by readAllocationNode. We need to advance it to the first + // remaining node. + readAllocationNode = firstAllocationNode; + } + } + + // Called by the loading thread. + + public long getTotalBytesWritten() { + return totalBytesWritten; + } + + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + length = preAppend(length); + int bytesAppended = + input.read( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + length); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } + throw new EOFException(); + } + postAppend(bytesAppended); + return bytesAppended; + } + + public void sampleData(ParsableByteArray buffer, int length) { + while (length > 0) { + int bytesAppended = preAppend(length); + buffer.readBytes( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + bytesAppended); + length -= bytesAppended; + postAppend(bytesAppended); + } + } + + // Private methods. + + /** + * Reads encryption data for the current sample. + * + * <p>The encryption data is written into {@link DecoderInputBuffer#cryptoInfo}, and {@link + * SampleExtrasHolder#size} is adjusted to subtract the number of bytes that were read. The same + * value is added to {@link SampleExtrasHolder#offset}. + * + * @param buffer The buffer into which the encryption data should be written. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + long offset = extrasHolder.offset; + + // Read the signal byte. + scratch.reset(1); + readData(offset, scratch.data, 1); + offset++; + byte signalByte = scratch.data[0]; + boolean subsampleEncryption = (signalByte & 0x80) != 0; + int ivSize = signalByte & 0x7F; + + // Read the initialization vector. + CryptoInfo cryptoInfo = buffer.cryptoInfo; + if (cryptoInfo.iv == null) { + cryptoInfo.iv = new byte[16]; + } else { + // Zero out cryptoInfo.iv so that if ivSize < 16, the remaining bytes are correctly set to 0. + Arrays.fill(cryptoInfo.iv, (byte) 0); + } + readData(offset, cryptoInfo.iv, ivSize); + offset += ivSize; + + // Read the subsample count, if present. + int subsampleCount; + if (subsampleEncryption) { + scratch.reset(2); + readData(offset, scratch.data, 2); + offset += 2; + subsampleCount = scratch.readUnsignedShort(); + } else { + subsampleCount = 1; + } + + // Write the clear and encrypted subsample sizes. + @Nullable int[] clearDataSizes = cryptoInfo.numBytesOfClearData; + if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { + clearDataSizes = new int[subsampleCount]; + } + @Nullable int[] encryptedDataSizes = cryptoInfo.numBytesOfEncryptedData; + if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { + encryptedDataSizes = new int[subsampleCount]; + } + if (subsampleEncryption) { + int subsampleDataLength = 6 * subsampleCount; + scratch.reset(subsampleDataLength); + readData(offset, scratch.data, subsampleDataLength); + offset += subsampleDataLength; + scratch.setPosition(0); + for (int i = 0; i < subsampleCount; i++) { + clearDataSizes[i] = scratch.readUnsignedShort(); + encryptedDataSizes[i] = scratch.readUnsignedIntToInt(); + } + } else { + clearDataSizes[0] = 0; + encryptedDataSizes[0] = extrasHolder.size - (int) (offset - extrasHolder.offset); + } + + // Populate the cryptoInfo. + CryptoData cryptoData = extrasHolder.cryptoData; + cryptoInfo.set( + subsampleCount, + clearDataSizes, + encryptedDataSizes, + cryptoData.encryptionKey, + cryptoInfo.iv, + cryptoData.cryptoMode, + cryptoData.encryptedBlocks, + cryptoData.clearBlocks); + + // Adjust the offset and size to take into account the bytes read. + int bytesRead = (int) (offset - extrasHolder.offset); + extrasHolder.offset += bytesRead; + extrasHolder.size -= bytesRead; + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The buffer into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, ByteBuffer target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + target.put(allocation.data, readAllocationNode.translateOffset(absolutePosition), toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The array into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, byte[] target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + System.arraycopy( + allocation.data, + readAllocationNode.translateOffset(absolutePosition), + target, + length - remaining, + toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The position to which {@link #readAllocationNode} should be advanced. + */ + private void advanceReadTo(long absolutePosition) { + while (absolutePosition >= readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + + /** + * Clears allocation nodes starting from {@code fromNode}. + * + * @param fromNode The node from which to clear. + */ + private void clearAllocationNodes(AllocationNode fromNode) { + if (!fromNode.wasInitialized) { + return; + } + // Bulk release allocations for performance (it's significantly faster when using + // DefaultAllocator because the allocator's lock only needs to be acquired and released once) + // [Internal: See b/29542039]. + int allocationCount = + (writeAllocationNode.wasInitialized ? 1 : 0) + + ((int) (writeAllocationNode.startPosition - fromNode.startPosition) + / allocationLength); + Allocation[] allocationsToRelease = new Allocation[allocationCount]; + AllocationNode currentNode = fromNode; + for (int i = 0; i < allocationsToRelease.length; i++) { + allocationsToRelease[i] = currentNode.allocation; + currentNode = currentNode.clear(); + } + allocator.release(allocationsToRelease); + } + + /** + * Called before writing sample data to {@link #writeAllocationNode}. May cause {@link + * #writeAllocationNode} to be initialized. + * + * @param length The number of bytes that the caller wishes to write. + * @return The number of bytes that the caller is permitted to write, which may be less than + * {@code length}. + */ + private int preAppend(int length) { + if (!writeAllocationNode.wasInitialized) { + writeAllocationNode.initialize( + allocator.allocate(), + new AllocationNode(writeAllocationNode.endPosition, allocationLength)); + } + return Math.min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); + } + + /** + * Called after writing sample data. May cause {@link #writeAllocationNode} to be advanced. + * + * @param length The number of bytes that were written. + */ + private void postAppend(int length) { + totalBytesWritten += length; + if (totalBytesWritten == writeAllocationNode.endPosition) { + writeAllocationNode = writeAllocationNode.next; + } + } + + /** A node in a linked list of {@link Allocation}s held by the output. */ + private static final class AllocationNode { + + /** The absolute position of the start of the data (inclusive). */ + public final long startPosition; + /** The absolute position of the end of the data (exclusive). */ + public final long endPosition; + /** Whether the node has been initialized. Remains true after {@link #clear()}. */ + public boolean wasInitialized; + /** The {@link Allocation}, or {@code null} if the node is not initialized. */ + @Nullable public Allocation allocation; + /** + * The next {@link AllocationNode} in the list, or {@code null} if the node has not been + * initialized. Remains set after {@link #clear()}. + */ + @Nullable public AllocationNode next; + + /** + * @param startPosition See {@link #startPosition}. + * @param allocationLength The length of the {@link Allocation} with which this node will be + * initialized. + */ + public AllocationNode(long startPosition, int allocationLength) { + this.startPosition = startPosition; + this.endPosition = startPosition + allocationLength; + } + + /** + * Initializes the node. + * + * @param allocation The node's {@link Allocation}. + * @param next The next {@link AllocationNode}. + */ + public void initialize(Allocation allocation, AllocationNode next) { + this.allocation = allocation; + this.next = next; + wasInitialized = true; + } + + /** + * Gets the offset into the {@link #allocation}'s {@link Allocation#data} that corresponds to + * the specified absolute position. + * + * @param absolutePosition The absolute position. + * @return The corresponding offset into the allocation's data. + */ + public int translateOffset(long absolutePosition) { + return (int) (absolutePosition - startPosition) + allocation.offset; + } + + /** + * Clears {@link #allocation} and {@link #next}. + * + * @return The cleared next {@link AllocationNode}. + */ + public AllocationNode clear() { + allocation = null; + AllocationNode temp = next; + next = null; + return temp; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java new file mode 100644 index 0000000000..639cccee00 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java @@ -0,0 +1,926 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Looper; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** A queue of media samples. */ +public class SampleQueue implements TrackOutput { + + /** A listener for changes to the upstream format. */ + public interface UpstreamFormatChangedListener { + + /** + * Called on the loading thread when an upstream format change occurs. + * + * @param format The new upstream format. + */ + void onUpstreamFormatChanged(Format format); + } + + @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; + + private final SampleDataQueue sampleDataQueue; + private final SampleExtrasHolder extrasHolder; + private final DrmSessionManager<?> drmSessionManager; + private UpstreamFormatChangedListener upstreamFormatChangeListener; + + @Nullable private Format downstreamFormat; + @Nullable private DrmSession<?> currentDrmSession; + + private int capacity; + private int[] sourceIds; + private long[] offsets; + private int[] sizes; + private int[] flags; + private long[] timesUs; + private CryptoData[] cryptoDatas; + private Format[] formats; + + private int length; + private int absoluteFirstIndex; + private int relativeFirstIndex; + private int readPosition; + + private long largestDiscardedTimestampUs; + private long largestQueuedTimestampUs; + private boolean isLastSampleQueued; + private boolean upstreamKeyframeRequired; + private boolean upstreamFormatRequired; + private Format upstreamFormat; + private Format upstreamCommittedFormat; + private int upstreamSourceId; + + private boolean pendingUpstreamFormatAdjustment; + private Format unadjustedUpstreamFormat; + private long sampleOffsetUs; + private boolean pendingSplice; + + /** + * Creates a sample queue. + * + * @param allocator An {@link Allocator} from which allocations for sample data can be obtained. + * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} + * from. The created instance does not take ownership of this {@link DrmSessionManager}. + */ + public SampleQueue(Allocator allocator, DrmSessionManager<?> drmSessionManager) { + sampleDataQueue = new SampleDataQueue(allocator); + this.drmSessionManager = drmSessionManager; + extrasHolder = new SampleExtrasHolder(); + capacity = SAMPLE_CAPACITY_INCREMENT; + sourceIds = new int[capacity]; + offsets = new long[capacity]; + timesUs = new long[capacity]; + flags = new int[capacity]; + sizes = new int[capacity]; + cryptoDatas = new CryptoData[capacity]; + formats = new Format[capacity]; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + upstreamFormatRequired = true; + upstreamKeyframeRequired = true; + } + + // Called by the consuming thread when there is no loading thread. + + /** Calls {@link #reset(boolean) reset(true)} and releases any resources owned by the queue. */ + @CallSuper + public void release() { + reset(/* resetUpstreamFormat= */ true); + releaseDrmSessionReferences(); + } + + /** Convenience method for {@code reset(false)}. */ + public final void reset() { + reset(/* resetUpstreamFormat= */ false); + } + + /** + * Clears all samples from the queue. + * + * @param resetUpstreamFormat Whether the upstream format should be cleared. If set to false, + * samples queued after the reset (and before a subsequent call to {@link #format(Format)}) + * are assumed to have the current upstream format. If set to true, {@link #format(Format)} + * must be called after the reset before any more samples can be queued. + */ + @CallSuper + public void reset(boolean resetUpstreamFormat) { + sampleDataQueue.reset(); + length = 0; + absoluteFirstIndex = 0; + relativeFirstIndex = 0; + readPosition = 0; + upstreamKeyframeRequired = true; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + isLastSampleQueued = false; + upstreamCommittedFormat = null; + if (resetUpstreamFormat) { + unadjustedUpstreamFormat = null; + upstreamFormat = null; + upstreamFormatRequired = true; + } + } + + /** + * Sets a source identifier for subsequent samples. + * + * @param sourceId The source identifier. + */ + public final void sourceId(int sourceId) { + upstreamSourceId = sourceId; + } + + /** Indicates samples that are subsequently queued should be spliced into those already queued. */ + public final void splice() { + pendingSplice = true; + } + + /** Returns the current absolute write index. */ + public final int getWriteIndex() { + return absoluteFirstIndex + length; + } + + /** + * Discards samples from the write side of the queue. + * + * @param discardFromIndex The absolute index of the first sample to be discarded. Must be in the + * range [{@link #getReadIndex()}, {@link #getWriteIndex()}]. + */ + public final void discardUpstreamSamples(int discardFromIndex) { + sampleDataQueue.discardUpstreamSampleBytes(discardUpstreamSampleMetadata(discardFromIndex)); + } + + // Called by the consuming thread. + + /** Calls {@link #discardToEnd()} and releases any resources owned by the queue. */ + @CallSuper + public void preRelease() { + discardToEnd(); + releaseDrmSessionReferences(); + } + + /** + * Throws an error that's preventing data from being read. Does nothing if no such error exists. + * + * @throws IOException The underlying error. + */ + @CallSuper + public void maybeThrowError() throws IOException { + // TODO: Avoid throwing if the DRM error is not preventing a read operation. + if (currentDrmSession != null && currentDrmSession.getState() == DrmSession.STATE_ERROR) { + throw Assertions.checkNotNull(currentDrmSession.getError()); + } + } + + /** Returns the current absolute start index. */ + public final int getFirstIndex() { + return absoluteFirstIndex; + } + + /** Returns the current absolute read index. */ + public final int getReadIndex() { + return absoluteFirstIndex + readPosition; + } + + /** + * Peeks the source id of the next sample to be read, or the current upstream source id if the + * queue is empty or if the read position is at the end of the queue. + * + * @return The source id. + */ + public final synchronized int peekSourceId() { + int relativeReadIndex = getRelativeIndex(readPosition); + return hasNextSample() ? sourceIds[relativeReadIndex] : upstreamSourceId; + } + + /** Returns the upstream {@link Format} in which samples are being queued. */ + public final synchronized Format getUpstreamFormat() { + return upstreamFormatRequired ? null : upstreamFormat; + } + + /** + * Returns the largest sample timestamp that has been queued since the last {@link #reset}. + * + * <p>Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + * + * @return The largest sample timestamp that has been queued, or {@link Long#MIN_VALUE} if no + * samples have been queued. + */ + public final synchronized long getLargestQueuedTimestampUs() { + return largestQueuedTimestampUs; + } + + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + * + * <p>Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + */ + public final synchronized boolean isLastSampleQueued() { + return isLastSampleQueued; + } + + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ + public final synchronized long getFirstTimestampUs() { + return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; + } + + /** + * Returns whether there is data available for reading. + * + * <p>Note: If the stream has ended then a buffer with the end of stream flag can always be read + * from {@link #read}. Hence an ended stream is always ready. + * + * @param loadingFinished Whether no more samples will be written to the sample queue. When true, + * this method returns true if the sample queue is empty, because an empty sample queue means + * the end of stream has been reached. When false, this method returns false if the sample + * queue is empty. + */ + @SuppressWarnings("ReferenceEquality") // See comments in setUpstreamFormat + @CallSuper + public synchronized boolean isReady(boolean loadingFinished) { + if (!hasNextSample()) { + return loadingFinished + || isLastSampleQueued + || (upstreamFormat != null && upstreamFormat != downstreamFormat); + } + int relativeReadIndex = getRelativeIndex(readPosition); + if (formats[relativeReadIndex] != downstreamFormat) { + // A format can be read. + return true; + } + return mayReadSample(relativeReadIndex); + } + + /** + * Attempts to read from the queue. + * + * <p>{@link Format Formats} read from this method may be associated to a {@link DrmSession} + * through {@link FormatHolder#drmSession}, which is populated in two scenarios: + * + * <ul> + * <li>The {@link Format} has a non-null {@link Format#drmInitData}. + * <li>The {@link DrmSessionManager} provides placeholder sessions for this queue's track type. + * See {@link DrmSessionManager#acquirePlaceholderSession(Looper, int)}. + * </ul> + * + * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. + * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the + * end of the stream. If the end of the stream has been reached, the {@link + * C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. If a {@link + * DecoderInputBuffer#isFlagsOnly() flags-only} buffer is passed, only the buffer flags may be + * populated by this method and the read position of the queue will not change. + * @param formatRequired Whether the caller requires that the format of the stream be read even if + * it's not changing. A sample will never be read if set to true, however it is still possible + * for the end of stream or nothing to be read. + * @param loadingFinished True if an empty queue should be considered the end of the stream. + * @param decodeOnlyUntilUs If a buffer is read, the {@link C#BUFFER_FLAG_DECODE_ONLY} flag will + * be set if the buffer's timestamp is less than this value. + * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or + * {@link C#RESULT_BUFFER_READ}. + */ + @CallSuper + public int read( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired, + boolean loadingFinished, + long decodeOnlyUntilUs) { + int result = + readSampleMetadata( + formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilUs, extrasHolder); + if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream() && !buffer.isFlagsOnly()) { + sampleDataQueue.readToBuffer(buffer, extrasHolder); + } + return result; + } + + /** + * Attempts to seek the read position to the specified sample index. + * + * @param sampleIndex The sample index. + * @return Whether the seek was successful. + */ + public final synchronized boolean seekTo(int sampleIndex) { + rewind(); + if (sampleIndex < absoluteFirstIndex || sampleIndex > absoluteFirstIndex + length) { + return false; + } + readPosition = sampleIndex - absoluteFirstIndex; + return true; + } + + /** + * Attempts to seek the read position to the keyframe before or at the specified time. + * + * @param timeUs The time to seek to. + * @param allowTimeBeyondBuffer Whether the operation can succeed if {@code timeUs} is beyond the + * end of the queue, by seeking to the last sample (or keyframe). + * @return Whether the seek was successful. + */ + public final synchronized boolean seekTo(long timeUs, boolean allowTimeBeyondBuffer) { + rewind(); + int relativeReadIndex = getRelativeIndex(readPosition); + if (!hasNextSample() + || timeUs < timesUs[relativeReadIndex] + || (timeUs > largestQueuedTimestampUs && !allowTimeBeyondBuffer)) { + return false; + } + int offset = + findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); + if (offset == -1) { + return false; + } + readPosition += offset; + return true; + } + + /** + * Advances the read position to the keyframe before or at the specified time. + * + * @param timeUs The time to advance to. + * @return The number of samples that were skipped, which may be equal to 0. + */ + public final synchronized int advanceTo(long timeUs) { + int relativeReadIndex = getRelativeIndex(readPosition); + if (!hasNextSample() || timeUs < timesUs[relativeReadIndex]) { + return 0; + } + int offset = + findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); + if (offset == -1) { + return 0; + } + readPosition += offset; + return offset; + } + + /** + * Advances the read position to the end of the queue. + * + * @return The number of samples that were skipped. + */ + public final synchronized int advanceToEnd() { + int skipCount = length - readPosition; + readPosition = length; + return skipCount; + } + + /** + * Discards up to but not including the sample immediately before or at the specified time. + * + * @param timeUs The time to discard up to. + * @param toKeyframe If true then discards samples up to the keyframe before or at the specified + * time, rather than any sample before or at that time. + * @param stopAtReadPosition If true then samples are only discarded if they're before the read + * position. If false then samples at and beyond the read position may be discarded, in which + * case the read position is advanced to the first remaining sample. + */ + public final void discardTo(long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + sampleDataQueue.discardDownstreamTo( + discardSampleMetadataTo(timeUs, toKeyframe, stopAtReadPosition)); + } + + /** Discards up to but not including the read position. */ + public final void discardToRead() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToRead()); + } + + /** Discards all samples in the queue and advances the read position. */ + public final void discardToEnd() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToEnd()); + } + + // Called by the loading thread. + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that + * are subsequently queued. + * + * @param sampleOffsetUs The timestamp offset in microseconds. + */ + public final void setSampleOffsetUs(long sampleOffsetUs) { + if (this.sampleOffsetUs != sampleOffsetUs) { + this.sampleOffsetUs = sampleOffsetUs; + invalidateUpstreamFormatAdjustment(); + } + } + + /** + * Sets a listener to be notified of changes to the upstream format. + * + * @param listener The listener. + */ + public final void setUpstreamFormatChangeListener(UpstreamFormatChangedListener listener) { + upstreamFormatChangeListener = listener; + } + + // TrackOutput implementation. Called by the loading thread. + + @Override + public final void format(Format unadjustedUpstreamFormat) { + Format adjustedUpstreamFormat = getAdjustedUpstreamFormat(unadjustedUpstreamFormat); + pendingUpstreamFormatAdjustment = false; + this.unadjustedUpstreamFormat = unadjustedUpstreamFormat; + boolean upstreamFormatChanged = setUpstreamFormat(adjustedUpstreamFormat); + if (upstreamFormatChangeListener != null && upstreamFormatChanged) { + upstreamFormatChangeListener.onUpstreamFormatChanged(adjustedUpstreamFormat); + } + } + + @Override + public final int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + return sampleDataQueue.sampleData(input, length, allowEndOfInput); + } + + @Override + public final void sampleData(ParsableByteArray buffer, int length) { + sampleDataQueue.sampleData(buffer, length); + } + + @Override + public final void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + if (pendingUpstreamFormatAdjustment) { + format(unadjustedUpstreamFormat); + } + timeUs += sampleOffsetUs; + if (pendingSplice) { + if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0 || !attemptSplice(timeUs)) { + return; + } + pendingSplice = false; + } + long absoluteOffset = sampleDataQueue.getTotalBytesWritten() - size - offset; + commitSample(timeUs, flags, absoluteOffset, size, cryptoData); + } + + /** + * Invalidates the last upstream format adjustment. {@link #getAdjustedUpstreamFormat(Format)} + * will be called to adjust the upstream {@link Format} again before the next sample is queued. + */ + protected final void invalidateUpstreamFormatAdjustment() { + pendingUpstreamFormatAdjustment = true; + } + + /** + * Adjusts the upstream {@link Format} (i.e., the {@link Format} that was most recently passed to + * {@link #format(Format)}). + * + * <p>The default implementation incorporates the sample offset passed to {@link + * #setSampleOffsetUs(long)} into {@link Format#subsampleOffsetUs}. + * + * @param format The {@link Format} to adjust. + * @return The adjusted {@link Format}. + */ + @CallSuper + protected Format getAdjustedUpstreamFormat(Format format) { + if (sampleOffsetUs != 0 && format.subsampleOffsetUs != Format.OFFSET_SAMPLE_RELATIVE) { + format = format.copyWithSubsampleOffsetUs(format.subsampleOffsetUs + sampleOffsetUs); + } + return format; + } + + // Internal methods. + + /** Rewinds the read position to the first sample in the queue. */ + private synchronized void rewind() { + readPosition = 0; + sampleDataQueue.rewind(); + } + + @SuppressWarnings("ReferenceEquality") // See comments in setUpstreamFormat + private synchronized int readSampleMetadata( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired, + boolean loadingFinished, + long decodeOnlyUntilUs, + SampleExtrasHolder extrasHolder) { + buffer.waitingForKeys = false; + // This is a temporary fix for https://github.com/google/ExoPlayer/issues/6155. + // TODO: Remove it and replace it with a fix that discards samples when writing to the queue. + boolean hasNextSample; + int relativeReadIndex = C.INDEX_UNSET; + while ((hasNextSample = hasNextSample())) { + relativeReadIndex = getRelativeIndex(readPosition); + long timeUs = timesUs[relativeReadIndex]; + if (timeUs < decodeOnlyUntilUs + && MimeTypes.allSamplesAreSyncSamples(formats[relativeReadIndex].sampleMimeType)) { + readPosition++; + } else { + break; + } + } + + if (!hasNextSample) { + if (loadingFinished || isLastSampleQueued) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } else if (upstreamFormat != null && (formatRequired || upstreamFormat != downstreamFormat)) { + onFormatResult(Assertions.checkNotNull(upstreamFormat), formatHolder); + return C.RESULT_FORMAT_READ; + } else { + return C.RESULT_NOTHING_READ; + } + } + + if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { + onFormatResult(formats[relativeReadIndex], formatHolder); + return C.RESULT_FORMAT_READ; + } + + if (!mayReadSample(relativeReadIndex)) { + buffer.waitingForKeys = true; + return C.RESULT_NOTHING_READ; + } + + buffer.setFlags(flags[relativeReadIndex]); + buffer.timeUs = timesUs[relativeReadIndex]; + if (buffer.timeUs < decodeOnlyUntilUs) { + buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); + } + if (buffer.isFlagsOnly()) { + return C.RESULT_BUFFER_READ; + } + extrasHolder.size = sizes[relativeReadIndex]; + extrasHolder.offset = offsets[relativeReadIndex]; + extrasHolder.cryptoData = cryptoDatas[relativeReadIndex]; + + readPosition++; + return C.RESULT_BUFFER_READ; + } + + private synchronized boolean setUpstreamFormat(Format format) { + if (format == null) { + upstreamFormatRequired = true; + return false; + } + upstreamFormatRequired = false; + if (Util.areEqual(format, upstreamFormat)) { + // The format is unchanged. If format and upstreamFormat are different objects, we keep the + // current upstreamFormat so we can detect format changes on the read side using cheap + // referential quality. + return false; + } else if (Util.areEqual(format, upstreamCommittedFormat)) { + // The format has changed back to the format of the last committed sample. If they are + // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat + // so we can detect format changes on the read side using cheap referential equality. + upstreamFormat = upstreamCommittedFormat; + return true; + } else { + upstreamFormat = format; + return true; + } + } + + private synchronized long discardSampleMetadataTo( + long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + if (length == 0 || timeUs < timesUs[relativeFirstIndex]) { + return C.POSITION_UNSET; + } + int searchLength = stopAtReadPosition && readPosition != length ? readPosition + 1 : length; + int discardCount = findSampleBefore(relativeFirstIndex, searchLength, timeUs, toKeyframe); + if (discardCount == -1) { + return C.POSITION_UNSET; + } + return discardSamples(discardCount); + } + + public synchronized long discardSampleMetadataToRead() { + if (readPosition == 0) { + return C.POSITION_UNSET; + } + return discardSamples(readPosition); + } + + private synchronized long discardSampleMetadataToEnd() { + if (length == 0) { + return C.POSITION_UNSET; + } + return discardSamples(length); + } + + private void releaseDrmSessionReferences() { + if (currentDrmSession != null) { + currentDrmSession.release(); + currentDrmSession = null; + // Clear downstream format to avoid violating the assumption that downstreamFormat.drmInitData + // != null implies currentSession != null + downstreamFormat = null; + } + } + + private synchronized void commitSample( + long timeUs, @C.BufferFlags int sampleFlags, long offset, int size, CryptoData cryptoData) { + if (upstreamKeyframeRequired) { + if ((sampleFlags & C.BUFFER_FLAG_KEY_FRAME) == 0) { + return; + } + upstreamKeyframeRequired = false; + } + Assertions.checkState(!upstreamFormatRequired); + + isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); + + int relativeEndIndex = getRelativeIndex(length); + timesUs[relativeEndIndex] = timeUs; + offsets[relativeEndIndex] = offset; + sizes[relativeEndIndex] = size; + flags[relativeEndIndex] = sampleFlags; + cryptoDatas[relativeEndIndex] = cryptoData; + formats[relativeEndIndex] = upstreamFormat; + sourceIds[relativeEndIndex] = upstreamSourceId; + upstreamCommittedFormat = upstreamFormat; + + length++; + if (length == capacity) { + // Increase the capacity. + int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; + int[] newSourceIds = new int[newCapacity]; + long[] newOffsets = new long[newCapacity]; + long[] newTimesUs = new long[newCapacity]; + int[] newFlags = new int[newCapacity]; + int[] newSizes = new int[newCapacity]; + CryptoData[] newCryptoDatas = new CryptoData[newCapacity]; + Format[] newFormats = new Format[newCapacity]; + int beforeWrap = capacity - relativeFirstIndex; + System.arraycopy(offsets, relativeFirstIndex, newOffsets, 0, beforeWrap); + System.arraycopy(timesUs, relativeFirstIndex, newTimesUs, 0, beforeWrap); + System.arraycopy(flags, relativeFirstIndex, newFlags, 0, beforeWrap); + System.arraycopy(sizes, relativeFirstIndex, newSizes, 0, beforeWrap); + System.arraycopy(cryptoDatas, relativeFirstIndex, newCryptoDatas, 0, beforeWrap); + System.arraycopy(formats, relativeFirstIndex, newFormats, 0, beforeWrap); + System.arraycopy(sourceIds, relativeFirstIndex, newSourceIds, 0, beforeWrap); + int afterWrap = relativeFirstIndex; + System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); + System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); + System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); + System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); + System.arraycopy(cryptoDatas, 0, newCryptoDatas, beforeWrap, afterWrap); + System.arraycopy(formats, 0, newFormats, beforeWrap, afterWrap); + System.arraycopy(sourceIds, 0, newSourceIds, beforeWrap, afterWrap); + offsets = newOffsets; + timesUs = newTimesUs; + flags = newFlags; + sizes = newSizes; + cryptoDatas = newCryptoDatas; + formats = newFormats; + sourceIds = newSourceIds; + relativeFirstIndex = 0; + capacity = newCapacity; + } + } + + /** + * Attempts to discard samples from the end of the queue to allow samples starting from the + * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. + * + * @param timeUs The timestamp at which the splice occurs. + * @return Whether the splice was successful. + */ + private synchronized boolean attemptSplice(long timeUs) { + if (length == 0) { + return timeUs > largestDiscardedTimestampUs; + } + long largestReadTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); + if (largestReadTimestampUs >= timeUs) { + return false; + } + int retainCount = length; + int relativeSampleIndex = getRelativeIndex(length - 1); + while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { + retainCount--; + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + discardUpstreamSampleMetadata(absoluteFirstIndex + retainCount); + return true; + } + + private long discardUpstreamSampleMetadata(int discardFromIndex) { + int discardCount = getWriteIndex() - discardFromIndex; + Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); + length -= discardCount; + largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + isLastSampleQueued = discardCount == 0 && isLastSampleQueued; + if (length != 0) { + int relativeLastWriteIndex = getRelativeIndex(length - 1); + return offsets[relativeLastWriteIndex] + sizes[relativeLastWriteIndex]; + } + return 0; + } + + private boolean hasNextSample() { + return readPosition != length; + } + + /** + * Sets the downstream format, performs DRM resource management, and populates the {@code + * outputFormatHolder}. + * + * @param newFormat The new downstream format. + * @param outputFormatHolder The output {@link FormatHolder}. + */ + private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { + outputFormatHolder.format = newFormat; + boolean isFirstFormat = downstreamFormat == null; + DrmInitData oldDrmInitData = isFirstFormat ? null : downstreamFormat.drmInitData; + downstreamFormat = newFormat; + if (drmSessionManager == DrmSessionManager.DUMMY) { + // Avoid attempting to acquire a session using the dummy DRM session manager. It's likely that + // the media source creation has not yet been migrated and the renderer can acquire the + // session for the read DRM init data. + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. + return; + } + DrmInitData newDrmInitData = newFormat.drmInitData; + outputFormatHolder.includesDrmSession = true; + outputFormatHolder.drmSession = currentDrmSession; + if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { + // Nothing to do. + return; + } + // Ensure we acquire the new session before releasing the previous one in case the same session + // is being used for both DrmInitData. + DrmSession<?> previousSession = currentDrmSession; + Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + currentDrmSession = + newDrmInitData != null + ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) + : drmSessionManager.acquirePlaceholderSession( + playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + outputFormatHolder.drmSession = currentDrmSession; + + if (previousSession != null) { + previousSession.release(); + } + } + + /** + * Returns whether it's possible to read the next sample. + * + * @param relativeReadIndex The relative read index of the next sample. + * @return Whether it's possible to read the next sample. + */ + private boolean mayReadSample(int relativeReadIndex) { + if (drmSessionManager == DrmSessionManager.DUMMY) { + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. + // For protected content it's likely that the DrmSessionManager is still being injected into + // the renderers. We assume that the renderers will be able to acquire a DrmSession if needed. + return true; + } + return currentDrmSession == null + || currentDrmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS + || ((flags[relativeReadIndex] & C.BUFFER_FLAG_ENCRYPTED) == 0 + && currentDrmSession.playClearSamplesWithoutKeys()); + } + + /** + * Finds the sample in the specified range that's before or at the specified time. If {@code + * keyframe} is {@code true} then the sample is additionally required to be a keyframe. + * + * @param relativeStartIndex The relative index from which to start searching. + * @param length The length of the range being searched. + * @param timeUs The specified time. + * @param keyframe Whether only keyframes should be considered. + * @return The offset from {@code relativeFirstIndex} to the found sample, or -1 if no matching + * sample was found. + */ + private int findSampleBefore(int relativeStartIndex, int length, long timeUs, boolean keyframe) { + // This could be optimized to use a binary search, however in practice callers to this method + // normally pass times near to the start of the search region. Hence it's unclear whether + // switching to a binary search would yield any real benefit. + int sampleCountToTarget = -1; + int searchIndex = relativeStartIndex; + for (int i = 0; i < length && timesUs[searchIndex] <= timeUs; i++) { + if (!keyframe || (flags[searchIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + // We've found a suitable sample. + sampleCountToTarget = i; + } + searchIndex++; + if (searchIndex == capacity) { + searchIndex = 0; + } + } + return sampleCountToTarget; + } + + /** + * Discards the specified number of samples. + * + * @param discardCount The number of samples to discard. + * @return The corresponding offset up to which data should be discarded. + */ + private long discardSamples(int discardCount) { + largestDiscardedTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); + length -= discardCount; + absoluteFirstIndex += discardCount; + relativeFirstIndex += discardCount; + if (relativeFirstIndex >= capacity) { + relativeFirstIndex -= capacity; + } + readPosition -= discardCount; + if (readPosition < 0) { + readPosition = 0; + } + if (length == 0) { + int relativeLastDiscardIndex = (relativeFirstIndex == 0 ? capacity : relativeFirstIndex) - 1; + return offsets[relativeLastDiscardIndex] + sizes[relativeLastDiscardIndex]; + } else { + return offsets[relativeFirstIndex]; + } + } + + /** + * Finds the largest timestamp of any sample from the start of the queue up to the specified + * length, assuming that the timestamps prior to a keyframe are always less than the timestamp of + * the keyframe itself, and of subsequent frames. + * + * @param length The length of the range being searched. + * @return The largest timestamp, or {@link Long#MIN_VALUE} if {@code length == 0}. + */ + private long getLargestTimestamp(int length) { + if (length == 0) { + return Long.MIN_VALUE; + } + long largestTimestampUs = Long.MIN_VALUE; + int relativeSampleIndex = getRelativeIndex(length - 1); + for (int i = 0; i < length; i++) { + largestTimestampUs = Math.max(largestTimestampUs, timesUs[relativeSampleIndex]); + if ((flags[relativeSampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + break; + } + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + return largestTimestampUs; + } + + /** + * Returns the relative index for a given offset from the start of the queue. + * + * @param offset The offset, which must be in the range [0, length]. + */ + private int getRelativeIndex(int offset) { + int relativeIndex = relativeFirstIndex + offset; + return relativeIndex < capacity ? relativeIndex : relativeIndex - capacity; + } + + /** A holder for sample metadata not held by {@link DecoderInputBuffer}. */ + /* package */ static final class SampleExtrasHolder { + + public int size; + public long offset; + public CryptoData cryptoData; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java new file mode 100644 index 0000000000..54a7d0f895 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import java.io.IOException; + +/** + * A stream of media samples (and associated format information). + */ +public interface SampleStream { + + /** + * Returns whether data is available to be read. + * <p> + * Note: If the stream has ended then a buffer with the end of stream flag can always be read from + * {@link #readData(FormatHolder, DecoderInputBuffer, boolean)}. Hence an ended stream is always + * ready. + * + * @return Whether data is available to be read. + */ + boolean isReady(); + + /** + * Throws an error that's preventing data from being read. Does nothing if no such error exists. + * + * @throws IOException The underlying error. + */ + void maybeThrowError() throws IOException; + + /** + * Attempts to read from the stream. + * + * <p>If the stream has ended then {@link C#BUFFER_FLAG_END_OF_STREAM} flag is set on {@code + * buffer} and {@link C#RESULT_BUFFER_READ} is returned. Else if no data is available then {@link + * C#RESULT_NOTHING_READ} is returned. Else if the format of the media is changing or if {@code + * formatRequired} is set then {@code formatHolder} is populated and {@link C#RESULT_FORMAT_READ} + * is returned. Else {@code buffer} is populated and {@link C#RESULT_BUFFER_READ} is returned. + * + * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. + * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the + * end of the stream. If the end of the stream has been reached, the {@link + * C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. If a {@link + * DecoderInputBuffer#isFlagsOnly() flags-only} buffer is passed, then no {@link + * DecoderInputBuffer#data} will be read and the read position of the stream will not change, + * but the flags of the buffer will be populated. + * @param formatRequired Whether the caller requires that the format of the stream be read even if + * it's not changing. A sample will never be read if set to true, however it is still possible + * for the end of stream or nothing to be read. + * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or + * {@link C#RESULT_BUFFER_READ}. + */ + int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired); + + /** + * Attempts to skip to the keyframe before the specified position, or to the end of the stream if + * {@code positionUs} is beyond it. + * + * @param positionUs The specified time. + * @return The number of samples that were skipped. + */ + int skipData(long positionUs); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java new file mode 100644 index 0000000000..09cb8b663b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +// TODO: Clarify the requirements for implementing this interface [Internal ref: b/36250203]. +/** + * A loader that can proceed in approximate synchronization with other loaders. + */ +public interface SequenceableLoader { + + /** + * A callback to be notified of {@link SequenceableLoader} events. + */ + interface Callback<T extends SequenceableLoader> { + + /** + * Called by the loader to indicate that it wishes for its {@link #continueLoading(long)} method + * to be called when it can continue to load data. Called on the playback thread. + */ + void onContinueLoadingRequested(T source); + + } + + /** + * Returns an estimate of the position up to which data is buffered. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the data is fully buffered. + */ + long getBufferedPositionUs(); + + /** + * Returns the next load time, or {@link C#TIME_END_OF_SOURCE} if loading has finished. + */ + long getNextLoadPositionUs(); + + /** + * Attempts to continue loading. + * + * @param positionUs The current playback position in microseconds. If playback of the period to + * which this loader belongs has not yet started, the value will be the starting position + * in the period minus the duration of any media in previous periods still to be played. + * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return + * a different value than prior to the call. False otherwise. + */ + boolean continueLoading(long positionUs); + + /** Returns whether the loader is currently loading. */ + boolean isLoading(); + + /** + * Re-evaluates the buffer given the playback position. + * + * <p>Re-evaluation may discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + void reevaluateBuffer(long positionUs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java new file mode 100644 index 0000000000..f137054145 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.Arrays; +import java.util.Random; + +/** + * Shuffled order of indices. + * + * <p>The shuffle order must be immutable to ensure thread safety. + */ +public interface ShuffleOrder { + + /** + * The default {@link ShuffleOrder} implementation for random shuffle order. + */ + class DefaultShuffleOrder implements ShuffleOrder { + + private final Random random; + private final int[] shuffled; + private final int[] indexInShuffled; + + /** + * Creates an instance with a specified length. + * + * @param length The length of the shuffle order. + */ + public DefaultShuffleOrder(int length) { + this(length, new Random()); + } + + /** + * Creates an instance with a specified length and the specified random seed. Shuffle orders of + * the same length initialized with the same random seed are guaranteed to be equal. + * + * @param length The length of the shuffle order. + * @param randomSeed A random seed. + */ + public DefaultShuffleOrder(int length, long randomSeed) { + this(length, new Random(randomSeed)); + } + + /** + * Creates an instance with a specified shuffle order and the specified random seed. The random + * seed is used for {@link #cloneAndInsert(int, int)} invocations. + * + * @param shuffledIndices The shuffled indices to use as order. + * @param randomSeed A random seed. + */ + public DefaultShuffleOrder(int[] shuffledIndices, long randomSeed) { + this(Arrays.copyOf(shuffledIndices, shuffledIndices.length), new Random(randomSeed)); + } + + private DefaultShuffleOrder(int length, Random random) { + this(createShuffledList(length, random), random); + } + + private DefaultShuffleOrder(int[] shuffled, Random random) { + this.shuffled = shuffled; + this.random = random; + this.indexInShuffled = new int[shuffled.length]; + for (int i = 0; i < shuffled.length; i++) { + indexInShuffled[shuffled[i]] = i; + } + } + + @Override + public int getLength() { + return shuffled.length; + } + + @Override + public int getNextIndex(int index) { + int shuffledIndex = indexInShuffled[index]; + return ++shuffledIndex < shuffled.length ? shuffled[shuffledIndex] : C.INDEX_UNSET; + } + + @Override + public int getPreviousIndex(int index) { + int shuffledIndex = indexInShuffled[index]; + return --shuffledIndex >= 0 ? shuffled[shuffledIndex] : C.INDEX_UNSET; + } + + @Override + public int getLastIndex() { + return shuffled.length > 0 ? shuffled[shuffled.length - 1] : C.INDEX_UNSET; + } + + @Override + public int getFirstIndex() { + return shuffled.length > 0 ? shuffled[0] : C.INDEX_UNSET; + } + + @Override + public ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount) { + int[] insertionPoints = new int[insertionCount]; + int[] insertionValues = new int[insertionCount]; + for (int i = 0; i < insertionCount; i++) { + insertionPoints[i] = random.nextInt(shuffled.length + 1); + int swapIndex = random.nextInt(i + 1); + insertionValues[i] = insertionValues[swapIndex]; + insertionValues[swapIndex] = i + insertionIndex; + } + Arrays.sort(insertionPoints); + int[] newShuffled = new int[shuffled.length + insertionCount]; + int indexInOldShuffled = 0; + int indexInInsertionList = 0; + for (int i = 0; i < shuffled.length + insertionCount; i++) { + if (indexInInsertionList < insertionCount + && indexInOldShuffled == insertionPoints[indexInInsertionList]) { + newShuffled[i] = insertionValues[indexInInsertionList++]; + } else { + newShuffled[i] = shuffled[indexInOldShuffled++]; + if (newShuffled[i] >= insertionIndex) { + newShuffled[i] += insertionCount; + } + } + } + return new DefaultShuffleOrder(newShuffled, new Random(random.nextLong())); + } + + @Override + public ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive) { + int numberOfElementsToRemove = indexToExclusive - indexFrom; + int[] newShuffled = new int[shuffled.length - numberOfElementsToRemove]; + int foundElementsCount = 0; + for (int i = 0; i < shuffled.length; i++) { + if (shuffled[i] >= indexFrom && shuffled[i] < indexToExclusive) { + foundElementsCount++; + } else { + newShuffled[i - foundElementsCount] = + shuffled[i] >= indexFrom ? shuffled[i] - numberOfElementsToRemove : shuffled[i]; + } + } + return new DefaultShuffleOrder(newShuffled, new Random(random.nextLong())); + } + + @Override + public ShuffleOrder cloneAndClear() { + return new DefaultShuffleOrder(/* length= */ 0, new Random(random.nextLong())); + } + + private static int[] createShuffledList(int length, Random random) { + int[] shuffled = new int[length]; + for (int i = 0; i < length; i++) { + int swapIndex = random.nextInt(i + 1); + shuffled[i] = shuffled[swapIndex]; + shuffled[swapIndex] = i; + } + return shuffled; + } + + } + + /** + * A {@link ShuffleOrder} implementation which does not shuffle. + */ + final class UnshuffledShuffleOrder implements ShuffleOrder { + + private final int length; + + /** + * Creates an instance with a specified length. + * + * @param length The length of the shuffle order. + */ + public UnshuffledShuffleOrder(int length) { + this.length = length; + } + + @Override + public int getLength() { + return length; + } + + @Override + public int getNextIndex(int index) { + return ++index < length ? index : C.INDEX_UNSET; + } + + @Override + public int getPreviousIndex(int index) { + return --index >= 0 ? index : C.INDEX_UNSET; + } + + @Override + public int getLastIndex() { + return length > 0 ? length - 1 : C.INDEX_UNSET; + } + + @Override + public int getFirstIndex() { + return length > 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount) { + return new UnshuffledShuffleOrder(length + insertionCount); + } + + @Override + public ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive) { + return new UnshuffledShuffleOrder(length - indexToExclusive + indexFrom); + } + + @Override + public ShuffleOrder cloneAndClear() { + return new UnshuffledShuffleOrder(/* length= */ 0); + } + } + + /** + * Returns length of shuffle order. + */ + int getLength(); + + /** + * Returns the next index in the shuffle order. + * + * @param index An index. + * @return The index after {@code index}, or {@link C#INDEX_UNSET} if {@code index} is the last + * element. + */ + int getNextIndex(int index); + + /** + * Returns the previous index in the shuffle order. + * + * @param index An index. + * @return The index before {@code index}, or {@link C#INDEX_UNSET} if {@code index} is the first + * element. + */ + int getPreviousIndex(int index); + + /** + * Returns the last index in the shuffle order, or {@link C#INDEX_UNSET} if the shuffle order is + * empty. + */ + int getLastIndex(); + + /** + * Returns the first index in the shuffle order, or {@link C#INDEX_UNSET} if the shuffle order is + * empty. + */ + int getFirstIndex(); + + /** + * Returns a copy of the shuffle order with newly inserted elements. + * + * @param insertionIndex The index in the unshuffled order at which elements are inserted. + * @param insertionCount The number of elements inserted at {@code insertionIndex}. + * @return A copy of this {@link ShuffleOrder} with newly inserted elements. + */ + ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount); + + /** + * Returns a copy of the shuffle order with a range of elements removed. + * + * @param indexFrom The starting index in the unshuffled order of the range to remove. + * @param indexToExclusive The smallest index (must be greater or equal to {@code indexFrom}) that + * will not be removed. + * @return A copy of this {@link ShuffleOrder} without the elements in the removed range. + */ + ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive); + + /** Returns a copy of the shuffle order with all elements removed. */ + ShuffleOrder cloneAndClear(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java new file mode 100644 index 0000000000..096cc66622 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Media source with a single period consisting of silent raw audio of a given duration. */ +public final class SilenceMediaSource extends BaseMediaSource { + + private static final int SAMPLE_RATE_HZ = 44100; + @C.PcmEncoding private static final int ENCODING = C.ENCODING_PCM_16BIT; + private static final int CHANNEL_COUNT = 2; + private static final Format FORMAT = + Format.createAudioSampleFormat( + /* id=*/ null, + MimeTypes.AUDIO_RAW, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + CHANNEL_COUNT, + SAMPLE_RATE_HZ, + ENCODING, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + private static final byte[] SILENCE_SAMPLE = + new byte[Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * 1024]; + + private final long durationUs; + + /** + * Creates a new media source providing silent audio of the given duration. + * + * @param durationUs The duration of silent audio to output, in microseconds. + */ + public SilenceMediaSource(long durationUs) { + Assertions.checkArgument(durationUs >= 0); + this.durationUs = durationUs; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + refreshSourceInfo( + new SinglePeriodTimeline( + durationUs, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false)); + } + + @Override + public void maybeThrowSourceInfoRefreshError() {} + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return new SilenceMediaPeriod(durationUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) {} + + @Override + protected void releaseSourceInternal() {} + + private static final class SilenceMediaPeriod implements MediaPeriod { + + private static final TrackGroupArray TRACKS = new TrackGroupArray(new TrackGroup(FORMAT)); + + private final long durationUs; + private final ArrayList<SampleStream> sampleStreams; + + public SilenceMediaPeriod(long durationUs) { + this.durationUs = durationUs; + sampleStreams = new ArrayList<>(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + callback.onPrepared(/* mediaPeriod= */ this); + } + + @Override + public void maybeThrowPrepareError() {} + + @Override + public TrackGroupArray getTrackGroups() { + return TRACKS; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + positionUs = constrainSeekPosition(positionUs); + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + sampleStreams.remove(streams[i]); + streams[i] = null; + } + if (streams[i] == null && selections[i] != null) { + SilenceSampleStream stream = new SilenceSampleStream(durationUs); + stream.seekTo(positionUs); + sampleStreams.add(stream); + streams[i] = stream; + streamResetFlags[i] = true; + } + } + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) {} + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(long positionUs) { + positionUs = constrainSeekPosition(positionUs); + for (int i = 0; i < sampleStreams.size(); i++) { + ((SilenceSampleStream) sampleStreams.get(i)).seekTo(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return constrainSeekPosition(positionUs); + } + + @Override + public long getBufferedPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public long getNextLoadPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public boolean continueLoading(long positionUs) { + return false; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public void reevaluateBuffer(long positionUs) {} + + private long constrainSeekPosition(long positionUs) { + return Util.constrainValue(positionUs, 0, durationUs); + } + } + + private static final class SilenceSampleStream implements SampleStream { + + private final long durationBytes; + + private boolean sentFormat; + private long positionBytes; + + public SilenceSampleStream(long durationUs) { + durationBytes = getAudioByteCount(durationUs); + seekTo(0); + } + + public void seekTo(long positionUs) { + positionBytes = Util.constrainValue(getAudioByteCount(positionUs), 0, durationBytes); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void maybeThrowError() {} + + @Override + public int readData( + FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { + if (!sentFormat || formatRequired) { + formatHolder.format = FORMAT; + sentFormat = true; + return C.RESULT_FORMAT_READ; + } + + long bytesRemaining = durationBytes - positionBytes; + if (bytesRemaining == 0) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + + int bytesToWrite = (int) Math.min(SILENCE_SAMPLE.length, bytesRemaining); + buffer.ensureSpaceForWrite(bytesToWrite); + buffer.data.put(SILENCE_SAMPLE, /* offset= */ 0, bytesToWrite); + buffer.timeUs = getAudioPositionUs(positionBytes); + buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); + positionBytes += bytesToWrite; + return C.RESULT_BUFFER_READ; + } + + @Override + public int skipData(long positionUs) { + long oldPositionBytes = positionBytes; + seekTo(positionUs); + return (int) ((positionBytes - oldPositionBytes) / SILENCE_SAMPLE.length); + } + } + + private static long getAudioByteCount(long durationUs) { + long audioSampleCount = durationUs * SAMPLE_RATE_HZ / C.MICROS_PER_SECOND; + return Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * audioSampleCount; + } + + private static long getAudioPositionUs(long bytes) { + long audioSampleCount = bytes / Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT); + return audioSampleCount * C.MICROS_PER_SECOND / SAMPLE_RATE_HZ; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java new file mode 100644 index 0000000000..72d805dfa3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * A {@link Timeline} consisting of a single period and static window. + */ +public final class SinglePeriodTimeline extends Timeline { + + private static final Object UID = new Object(); + + private final long presentationStartTimeMs; + private final long windowStartTimeMs; + private final long periodDurationUs; + private final long windowDurationUs; + private final long windowPositionInPeriodUs; + private final long windowDefaultStartPositionUs; + private final boolean isSeekable; + private final boolean isDynamic; + private final boolean isLive; + @Nullable private final Object tag; + @Nullable private final Object manifest; + + /** + * Creates a timeline containing a single period and a window that spans it. + * + * @param durationUs The duration of the period, in microseconds. + * @param isSeekable Whether seeking is supported within the period. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + */ + public SinglePeriodTimeline( + long durationUs, boolean isSeekable, boolean isDynamic, boolean isLive) { + this(durationUs, isSeekable, isDynamic, isLive, /* manifest= */ null, /* tag= */ null); + } + + /** + * Creates a timeline containing a single period and a window that spans it. + * + * @param durationUs The duration of the period, in microseconds. + * @param isSeekable Whether seeking is supported within the period. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be {@code null}. + * @param tag A tag used for {@link Window#tag}. + */ + public SinglePeriodTimeline( + long durationUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + durationUs, + durationUs, + /* windowPositionInPeriodUs= */ 0, + /* windowDefaultStartPositionUs= */ 0, + isSeekable, + isDynamic, + isLive, + manifest, + tag); + } + + /** + * Creates a timeline with one period, and a window of known duration starting at a specified + * position in the period. + * + * @param periodDurationUs The duration of the period in microseconds. + * @param windowDurationUs The duration of the window in microseconds. + * @param windowPositionInPeriodUs The position of the start of the window in the period, in + * microseconds. + * @param windowDefaultStartPositionUs The default position relative to the start of the window at + * which to begin playback, in microseconds. + * @param isSeekable Whether seeking is supported within the window. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be (@code null}. + * @param tag A tag used for {@link Timeline.Window#tag}. + */ + public SinglePeriodTimeline( + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + periodDurationUs, + windowDurationUs, + windowPositionInPeriodUs, + windowDefaultStartPositionUs, + isSeekable, + isDynamic, + isLive, + manifest, + tag); + } + + /** + * Creates a timeline with one period, and a window of known duration starting at a specified + * position in the period. + * + * @param presentationStartTimeMs The start time of the presentation in milliseconds since the + * epoch. + * @param windowStartTimeMs The window's start time in milliseconds since the epoch. + * @param periodDurationUs The duration of the period in microseconds. + * @param windowDurationUs The duration of the window in microseconds. + * @param windowPositionInPeriodUs The position of the start of the window in the period, in + * microseconds. + * @param windowDefaultStartPositionUs The default position relative to the start of the window at + * which to begin playback, in microseconds. + * @param isSeekable Whether seeking is supported within the window. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be {@code null}. + * @param tag A tag used for {@link Timeline.Window#tag}. + */ + public SinglePeriodTimeline( + long presentationStartTimeMs, + long windowStartTimeMs, + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this.presentationStartTimeMs = presentationStartTimeMs; + this.windowStartTimeMs = windowStartTimeMs; + this.periodDurationUs = periodDurationUs; + this.windowDurationUs = windowDurationUs; + this.windowPositionInPeriodUs = windowPositionInPeriodUs; + this.windowDefaultStartPositionUs = windowDefaultStartPositionUs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.isLive = isLive; + this.manifest = manifest; + this.tag = tag; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + Assertions.checkIndex(windowIndex, 0, 1); + long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs; + if (isDynamic && defaultPositionProjectionUs != 0) { + if (windowDurationUs == C.TIME_UNSET) { + // Don't allow projection into a window that has an unknown duration. + windowDefaultStartPositionUs = C.TIME_UNSET; + } else { + windowDefaultStartPositionUs += defaultPositionProjectionUs; + if (windowDefaultStartPositionUs > windowDurationUs) { + // The projection takes us beyond the end of the window. + windowDefaultStartPositionUs = C.TIME_UNSET; + } + } + } + return window.set( + Window.SINGLE_WINDOW_UID, + tag, + manifest, + presentationStartTimeMs, + windowStartTimeMs, + isSeekable, + isDynamic, + isLive, + windowDefaultStartPositionUs, + windowDurationUs, + 0, + 0, + windowPositionInPeriodUs); + } + + @Override + public int getPeriodCount() { + return 1; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + Assertions.checkIndex(periodIndex, 0, 1); + Object uid = setIds ? UID : null; + return period.set(/* id= */ null, uid, 0, periodDurationUs, -windowPositionInPeriodUs); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return UID.equals(uid) ? 0 : C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + Assertions.checkIndex(periodIndex, 0, 1); + return UID; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java new file mode 100644 index 0000000000..6c7d92dac9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link MediaPeriod} with a single sample. + */ +/* package */ final class SingleSampleMediaPeriod implements MediaPeriod, + Loader.Callback<SingleSampleMediaPeriod.SourceLoadable> { + + /** + * The initial size of the allocation used to hold the sample data. + */ + private static final int INITIAL_SAMPLE_SIZE = 1024; + + private final DataSpec dataSpec; + private final DataSource.Factory dataSourceFactory; + @Nullable private final TransferListener transferListener; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final TrackGroupArray tracks; + private final ArrayList<SampleStreamImpl> sampleStreams; + private final long durationUs; + + // Package private to avoid thunk methods. + /* package */ final Loader loader; + /* package */ final Format format; + /* package */ final boolean treatLoadErrorsAsEndOfStream; + + /* package */ boolean notifiedReadingStarted; + /* package */ boolean loadingFinished; + /* package */ byte @MonotonicNonNull [] sampleData; + /* package */ int sampleSize; + + public SingleSampleMediaPeriod( + DataSpec dataSpec, + DataSource.Factory dataSourceFactory, + @Nullable TransferListener transferListener, + Format format, + long durationUs, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + boolean treatLoadErrorsAsEndOfStream) { + this.dataSpec = dataSpec; + this.dataSourceFactory = dataSourceFactory; + this.transferListener = transferListener; + this.format = format; + this.durationUs = durationUs; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + tracks = new TrackGroupArray(new TrackGroup(format)); + sampleStreams = new ArrayList<>(); + loader = new Loader("Loader:SingleSampleMediaPeriod"); + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + loader.release(); + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + callback.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + // Do nothing. + } + + @Override + public TrackGroupArray getTrackGroups() { + return tracks; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + sampleStreams.remove(streams[i]); + streams[i] = null; + } + if (streams[i] == null && selections[i] != null) { + SampleStreamImpl stream = new SampleStreamImpl(); + sampleStreams.add(stream); + streams[i] = stream; + streamResetFlags[i] = true; + } + } + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + // Do nothing. + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + long elapsedRealtimeMs = + loader.startLoading( + new SourceLoadable(dataSpec, dataSource), + /* callback= */ this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA)); + eventDispatcher.loadStarted( + dataSpec, + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + return C.TIME_UNSET; + } + + @Override + public long getNextLoadPositionUs() { + return loadingFinished || loader.isLoading() ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public long getBufferedPositionUs() { + return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public long seekToUs(long positionUs) { + for (int i = 0; i < sampleStreams.size(); i++) { + sampleStreams.get(i).reset(); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(SourceLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + sampleSize = (int) loadable.dataSource.getBytesRead(); + sampleData = Assertions.checkNotNull(loadable.sampleData); + loadingFinished = true; + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + sampleSize); + } + + @Override + public void onLoadCanceled(SourceLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + } + + @Override + public LoadErrorAction onLoadError( + SourceLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long retryDelay = + loadErrorHandlingPolicy.getRetryDelayMsFor( + C.DATA_TYPE_MEDIA, loadDurationMs, error, errorCount); + boolean errorCanBePropagated = + retryDelay == C.TIME_UNSET + || errorCount + >= loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA); + + LoadErrorAction action; + if (treatLoadErrorsAsEndOfStream && errorCanBePropagated) { + loadingFinished = true; + action = Loader.DONT_RETRY; + } else { + action = + retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } + eventDispatcher.loadError( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead(), + error, + /* wasCanceled= */ !action.isRetry()); + return action; + } + + private final class SampleStreamImpl implements SampleStream { + + private static final int STREAM_STATE_SEND_FORMAT = 0; + private static final int STREAM_STATE_SEND_SAMPLE = 1; + private static final int STREAM_STATE_END_OF_STREAM = 2; + + private int streamState; + private boolean notifiedDownstreamFormat; + + public void reset() { + if (streamState == STREAM_STATE_END_OF_STREAM) { + streamState = STREAM_STATE_SEND_SAMPLE; + } + } + + @Override + public boolean isReady() { + return loadingFinished; + } + + @Override + public void maybeThrowError() throws IOException { + if (!treatLoadErrorsAsEndOfStream) { + loader.maybeThrowError(); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + maybeNotifyDownstreamFormat(); + if (streamState == STREAM_STATE_END_OF_STREAM) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } else if (requireFormat || streamState == STREAM_STATE_SEND_FORMAT) { + formatHolder.format = format; + streamState = STREAM_STATE_SEND_SAMPLE; + return C.RESULT_FORMAT_READ; + } else if (loadingFinished) { + if (sampleData != null) { + buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); + buffer.timeUs = 0; + if (buffer.isFlagsOnly()) { + return C.RESULT_BUFFER_READ; + } + buffer.ensureSpaceForWrite(sampleSize); + buffer.data.put(sampleData, 0, sampleSize); + } else { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + } + streamState = STREAM_STATE_END_OF_STREAM; + return C.RESULT_BUFFER_READ; + } + return C.RESULT_NOTHING_READ; + } + + @Override + public int skipData(long positionUs) { + maybeNotifyDownstreamFormat(); + if (positionUs > 0 && streamState != STREAM_STATE_END_OF_STREAM) { + streamState = STREAM_STATE_END_OF_STREAM; + return 1; + } + return 0; + } + + private void maybeNotifyDownstreamFormat() { + if (!notifiedDownstreamFormat) { + eventDispatcher.downstreamFormatChanged( + MimeTypes.getTrackType(format.sampleMimeType), + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaTimeUs= */ 0); + notifiedDownstreamFormat = true; + } + } + } + + /* package */ static final class SourceLoadable implements Loadable { + + public final DataSpec dataSpec; + + private final StatsDataSource dataSource; + + @Nullable private byte[] sampleData; + + // the constructor does not initialize fields: sampleData + @SuppressWarnings("nullness:initialization.fields.uninitialized") + public SourceLoadable(DataSpec dataSpec, DataSource dataSource) { + this.dataSpec = dataSpec; + this.dataSource = new StatsDataSource(dataSource); + } + + @Override + public void cancelLoad() { + // Never happens. + } + + @Override + public void load() throws IOException, InterruptedException { + // We always load from the beginning, so reset bytesRead to 0. + dataSource.resetBytesRead(); + try { + // Create and open the input. + dataSource.open(dataSpec); + // Load the sample data. + int result = 0; + while (result != C.RESULT_END_OF_INPUT) { + int sampleSize = (int) dataSource.getBytesRead(); + if (sampleData == null) { + sampleData = new byte[INITIAL_SAMPLE_SIZE]; + } else if (sampleSize == sampleData.length) { + sampleData = Arrays.copyOf(sampleData, sampleData.length * 2); + } + result = dataSource.read(sampleData, sampleSize, sampleData.length - sampleSize); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java new file mode 100644 index 0000000000..01f35ef775 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * Loads data at a given {@link Uri} as a single sample belonging to a single {@link MediaPeriod}. + */ +public final class SingleSampleMediaSource extends BaseMediaSource { + + /** + * Listener of {@link SingleSampleMediaSource} events. + * + * @deprecated Use {@link MediaSourceEventListener}. + */ + @Deprecated + public interface EventListener { + + /** + * Called when an error occurs loading media data. + * + * @param sourceId The id of the reporting {@link SingleSampleMediaSource}. + * @param e The cause of the failure. + */ + void onLoadError(int sourceId, IOException e); + + } + + /** Factory for {@link SingleSampleMediaSource}. */ + public static final class Factory { + + private final DataSource.Factory dataSourceFactory; + + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private boolean treatLoadErrorsAsEndOfStream; + private boolean isCreateCalled; + @Nullable private Object tag; + + /** + * Creates a factory for {@link SingleSampleMediaSource}s. + * + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = Assertions.checkNotNull(dataSourceFactory); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + } + + /** + * Sets a tag for the media source which will be published in the {@link Timeline} of the source + * as {@link Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. See {@link + * #setLoadErrorHandlingPolicy} for the default value. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)); + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets whether load errors will be treated as end-of-stream signal (load errors will not be + * propagated). The default value is false. + * + * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample + * streams, treating them as ended instead. If false, load errors will be propagated + * normally by {@link SampleStream#maybeThrowError()}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTreatLoadErrorsAsEndOfStream(boolean treatLoadErrorsAsEndOfStream) { + Assertions.checkState(!isCreateCalled); + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + return this; + } + + /** + * Returns a new {@link SingleSampleMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @param format The {@link Format} of the media stream. + * @param durationUs The duration of the media stream in microseconds. + * @return The new {@link SingleSampleMediaSource}. + */ + public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + isCreateCalled = true; + return new SingleSampleMediaSource( + uri, + dataSourceFactory, + format, + durationUs, + loadErrorHandlingPolicy, + treatLoadErrorsAsEndOfStream, + tag); + } + + /** + * @deprecated Use {@link #createMediaSource(Uri, Format, long)} and {@link + * #addEventListener(Handler, MediaSourceEventListener)} instead. + */ + @Deprecated + public SingleSampleMediaSource createMediaSource( + Uri uri, + Format format, + long durationUs, + @Nullable Handler eventHandler, + @Nullable MediaSourceEventListener eventListener) { + SingleSampleMediaSource mediaSource = createMediaSource(uri, format, durationUs); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + } + + private final DataSpec dataSpec; + private final DataSource.Factory dataSourceFactory; + private final Format format; + private final long durationUs; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final boolean treatLoadErrorsAsEndOfStream; + private final Timeline timeline; + @Nullable private final Object tag; + + @Nullable private TransferListener transferListener; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + @SuppressWarnings("deprecation") + public SingleSampleMediaSource( + Uri uri, DataSource.Factory dataSourceFactory, Format format, long durationUs) { + this( + uri, + dataSourceFactory, + format, + durationUs, + DefaultLoadErrorHandlingPolicy.DEFAULT_MIN_LOADABLE_RETRY_COUNT); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + int minLoadableRetryCount) { + this( + uri, + dataSourceFactory, + format, + durationUs, + new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), + /* treatLoadErrorsAsEndOfStream= */ false, + /* tag= */ null); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. + * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample + * streams, treating them as ended instead. If false, load errors will be propagated normally + * by {@link SampleStream#maybeThrowError()}. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + @SuppressWarnings("deprecation") + public SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + int minLoadableRetryCount, + Handler eventHandler, + EventListener eventListener, + int eventSourceId, + boolean treatLoadErrorsAsEndOfStream) { + this( + uri, + dataSourceFactory, + format, + durationUs, + new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), + treatLoadErrorsAsEndOfStream, + /* tag= */ null); + if (eventHandler != null && eventListener != null) { + addEventListener(eventHandler, new EventListenerWrapper(eventListener, eventSourceId)); + } + } + + private SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + boolean treatLoadErrorsAsEndOfStream, + @Nullable Object tag) { + this.dataSourceFactory = dataSourceFactory; + this.format = format; + this.durationUs = durationUs; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + this.tag = tag; + dataSpec = new DataSpec(uri, DataSpec.FLAG_ALLOW_GZIP); + timeline = + new SinglePeriodTimeline( + durationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + tag); + } + + // MediaSource implementation. + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + transferListener = mediaTransferListener; + refreshSourceInfo(timeline); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return new SingleSampleMediaPeriod( + dataSpec, + dataSourceFactory, + transferListener, + format, + durationUs, + loadErrorHandlingPolicy, + createEventDispatcher(id), + treatLoadErrorsAsEndOfStream); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((SingleSampleMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + // Do nothing. + } + + /** + * Wraps a deprecated {@link EventListener}, invoking its callback from the equivalent callback in + * {@link MediaSourceEventListener}. + */ + @Deprecated + @SuppressWarnings("deprecation") + private static final class EventListenerWrapper implements MediaSourceEventListener { + + private final EventListener eventListener; + private final int eventSourceId; + + public EventListenerWrapper(EventListener eventListener, int eventSourceId) { + this.eventListener = Assertions.checkNotNull(eventListener); + this.eventSourceId = eventSourceId; + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + eventListener.onLoadError(eventSourceId, error); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java new file mode 100644 index 0000000000..566238dbdb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Arrays; + +// TODO: Add an allowMultipleStreams boolean to indicate where the one stream per group restriction +// does not apply. +/** + * Defines a group of tracks exposed by a {@link MediaPeriod}. + * + * <p>A {@link MediaPeriod} is only able to provide one {@link SampleStream} corresponding to a + * group at any given time, however this {@link SampleStream} may adapt between multiple tracks + * within the group. + */ +public final class TrackGroup implements Parcelable { + + /** + * The number of tracks in the group. + */ + public final int length; + + private final Format[] formats; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param formats The track formats. Must not be null, contain null elements or be of length 0. + */ + public TrackGroup(Format... formats) { + Assertions.checkState(formats.length > 0); + this.formats = formats; + this.length = formats.length; + } + + /* package */ TrackGroup(Parcel in) { + length = in.readInt(); + formats = new Format[length]; + for (int i = 0; i < length; i++) { + formats[i] = in.readParcelable(Format.class.getClassLoader()); + } + } + + /** + * Returns the format of the track at a given index. + * + * @param index The index of the track. + * @return The track's format. + */ + public Format getFormat(int index) { + return formats[index]; + } + + /** + * Returns the index of the track with the given format in the group. The format is located by + * identity so, for example, {@code group.indexOf(group.getFormat(index)) == index} even if + * multiple tracks have formats that contain the same values. + * + * @param format The format. + * @return The index of the track, or {@link C#INDEX_UNSET} if no such track exists. + */ + @SuppressWarnings("ReferenceEquality") + public int indexOf(Format format) { + for (int i = 0; i < formats.length; i++) { + if (format == formats[i]) { + return i; + } + } + return C.INDEX_UNSET; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + Arrays.hashCode(formats); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackGroup other = (TrackGroup) obj; + return length == other.length && Arrays.equals(formats, other.formats); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(length); + for (int i = 0; i < length; i++) { + dest.writeParcelable(formats[i], 0); + } + } + + public static final Parcelable.Creator<TrackGroup> CREATOR = + new Parcelable.Creator<TrackGroup>() { + + @Override + public TrackGroup createFromParcel(Parcel in) { + return new TrackGroup(in); + } + + @Override + public TrackGroup[] newArray(int size) { + return new TrackGroup[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java new file mode 100644 index 0000000000..103a45080e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.Arrays; + +/** An array of {@link TrackGroup}s exposed by a {@link MediaPeriod}. */ +public final class TrackGroupArray implements Parcelable { + + /** + * The empty array. + */ + public static final TrackGroupArray EMPTY = new TrackGroupArray(); + + /** + * The number of groups in the array. Greater than or equal to zero. + */ + public final int length; + + private final TrackGroup[] trackGroups; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param trackGroups The groups. Must not be null or contain null elements, but may be empty. + */ + public TrackGroupArray(TrackGroup... trackGroups) { + this.trackGroups = trackGroups; + this.length = trackGroups.length; + } + + /* package */ TrackGroupArray(Parcel in) { + length = in.readInt(); + trackGroups = new TrackGroup[length]; + for (int i = 0; i < length; i++) { + trackGroups[i] = in.readParcelable(TrackGroup.class.getClassLoader()); + } + } + + /** + * Returns the group at a given index. + * + * @param index The index of the group. + * @return The group. + */ + public TrackGroup get(int index) { + return trackGroups[index]; + } + + /** + * Returns the index of a group within the array. + * + * @param group The group. + * @return The index of the group, or {@link C#INDEX_UNSET} if no such group exists. + */ + @SuppressWarnings("ReferenceEquality") + public int indexOf(TrackGroup group) { + for (int i = 0; i < length; i++) { + // Suppressed reference equality warning because this is looking for the index of a specific + // TrackGroup object, not the index of a potential equal TrackGroup. + if (trackGroups[i] == group) { + return i; + } + } + return C.INDEX_UNSET; + } + + /** + * Returns whether this track group array is empty. + */ + public boolean isEmpty() { + return length == 0; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + hashCode = Arrays.hashCode(trackGroups); + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackGroupArray other = (TrackGroupArray) obj; + return length == other.length && Arrays.equals(trackGroups, other.trackGroups); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(length); + for (int i = 0; i < length; i++) { + dest.writeParcelable(trackGroups[i], 0); + } + } + + public static final Parcelable.Creator<TrackGroupArray> CREATOR = + new Parcelable.Creator<TrackGroupArray>() { + + @Override + public TrackGroupArray createFromParcel(Parcel in) { + return new TrackGroupArray(in); + } + + @Override + public TrackGroupArray[] newArray(int size) { + return new TrackGroupArray[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java new file mode 100644 index 0000000000..ccb9d350fc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; + +/** + * Thrown if the input format was not recognized. + */ +public class UnrecognizedInputFormatException extends ParserException { + + /** + * The {@link Uri} from which the unrecognized data was read. + */ + public final Uri uri; + + /** + * @param message The detail message for the exception. + * @param uri The {@link Uri} from which the unrecognized data was read. + */ + public UnrecognizedInputFormatException(String message, Uri uri) { + super(message); + this.uri = uri; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java new file mode 100644 index 0000000000..83b5b1bc40 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -0,0 +1,486 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.net.Uri; +import androidx.annotation.CheckResult; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Represents ad group times relative to the start of the media and information on the state and + * URIs of ads within each ad group. + * + * <p>Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. + */ +public final class AdPlaybackState { + + /** + * Represents a group of ads, with information about their states. + * + * <p>Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. + */ + public static final class AdGroup { + + /** The number of ads in the ad group, or {@link C#LENGTH_UNSET} if unknown. */ + public final int count; + /** The URI of each ad in the ad group. */ + public final @NullableType Uri[] uris; + /** The state of each ad in the ad group. */ + @AdState public final int[] states; + /** The durations of each ad in the ad group, in microseconds. */ + public final long[] durationsUs; + + /** Creates a new ad group with an unspecified number of ads. */ + public AdGroup() { + this( + /* count= */ C.LENGTH_UNSET, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + + private AdGroup( + int count, @AdState int[] states, @NullableType Uri[] uris, long[] durationsUs) { + Assertions.checkArgument(states.length == uris.length); + this.count = count; + this.states = states; + this.uris = uris; + this.durationsUs = durationsUs; + } + + /** + * Returns the index of the first ad in the ad group that should be played, or {@link #count} if + * no ads should be played. + */ + public int getFirstAdIndexToPlay() { + return getNextAdIndexToPlay(-1); + } + + /** + * Returns the index of the next ad in the ad group that should be played after playing {@code + * lastPlayedAdIndex}, or {@link #count} if no later ads should be played. + */ + public int getNextAdIndexToPlay(int lastPlayedAdIndex) { + int nextAdIndexToPlay = lastPlayedAdIndex + 1; + while (nextAdIndexToPlay < states.length) { + if (states[nextAdIndexToPlay] == AD_STATE_UNAVAILABLE + || states[nextAdIndexToPlay] == AD_STATE_AVAILABLE) { + break; + } + nextAdIndexToPlay++; + } + return nextAdIndexToPlay; + } + + /** Returns whether the ad group has at least one ad that still needs to be played. */ + public boolean hasUnplayedAds() { + return count == C.LENGTH_UNSET || getFirstAdIndexToPlay() < count; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdGroup adGroup = (AdGroup) o; + return count == adGroup.count + && Arrays.equals(uris, adGroup.uris) + && Arrays.equals(states, adGroup.states) + && Arrays.equals(durationsUs, adGroup.durationsUs); + } + + @Override + public int hashCode() { + int result = count; + result = 31 * result + Arrays.hashCode(uris); + result = 31 * result + Arrays.hashCode(states); + result = 31 * result + Arrays.hashCode(durationsUs); + return result; + } + + /** + * Returns a new instance with the ad count set to {@code count}. This method may only be called + * if this instance's ad count has not yet been specified. + */ + @CheckResult + public AdGroup withAdCount(int count) { + Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); + long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); + @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad + * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link + * #AD_STATE_UNAVAILABLE}, which is the default state. + * + * <p>This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdUri(Uri uri, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + @NullableType Uri[] uris = Arrays.copyOf(this.uris, states.length); + uris[index] = uri; + states[index] = AD_STATE_AVAILABLE; + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified ad set to the specified {@code state}. The ad + * specified must currently either be in {@link #AD_STATE_UNAVAILABLE} or {@link + * #AD_STATE_AVAILABLE}. + * + * <p>This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdState(@AdState int state, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument( + states[index] == AD_STATE_UNAVAILABLE + || states[index] == AD_STATE_AVAILABLE + || states[index] == state); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + @NullableType + Uri[] uris = + this.uris.length == states.length ? this.uris : Arrays.copyOf(this.uris, states.length); + states[index] = state; + return new AdGroup(count, states, uris, durationsUs); + } + + /** Returns a new instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdGroup withAdDurationsUs(long[] durationsUs) { + Assertions.checkArgument(count == C.LENGTH_UNSET || durationsUs.length <= this.uris.length); + if (durationsUs.length < this.uris.length) { + durationsUs = copyDurationsUsWithSpaceForAdCount(durationsUs, uris.length); + } + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns an instance with all unavailable and available ads marked as skipped. If the ad count + * hasn't been set, it will be set to zero. + */ + @CheckResult + public AdGroup withAllAdsSkipped() { + if (count == C.LENGTH_UNSET) { + return new AdGroup( + /* count= */ 0, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + int count = this.states.length; + @AdState int[] states = Arrays.copyOf(this.states, count); + for (int i = 0; i < count; i++) { + if (states[i] == AD_STATE_AVAILABLE || states[i] == AD_STATE_UNAVAILABLE) { + states[i] = AD_STATE_SKIPPED; + } + } + return new AdGroup(count, states, uris, durationsUs); + } + + @CheckResult + private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { + int oldStateCount = states.length; + int newStateCount = Math.max(count, oldStateCount); + states = Arrays.copyOf(states, newStateCount); + Arrays.fill(states, oldStateCount, newStateCount, AD_STATE_UNAVAILABLE); + return states; + } + + @CheckResult + private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int count) { + int oldDurationsUsCount = durationsUs.length; + int newDurationsUsCount = Math.max(count, oldDurationsUsCount); + durationsUs = Arrays.copyOf(durationsUs, newDurationsUsCount); + Arrays.fill(durationsUs, oldDurationsUsCount, newDurationsUsCount, C.TIME_UNSET); + return durationsUs; + } + } + + /** + * Represents the state of an ad in an ad group. One of {@link #AD_STATE_UNAVAILABLE}, {@link + * #AD_STATE_AVAILABLE}, {@link #AD_STATE_SKIPPED}, {@link #AD_STATE_PLAYED} or {@link + * #AD_STATE_ERROR}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AD_STATE_UNAVAILABLE, + AD_STATE_AVAILABLE, + AD_STATE_SKIPPED, + AD_STATE_PLAYED, + AD_STATE_ERROR, + }) + public @interface AdState {} + /** State for an ad that does not yet have a URL. */ + public static final int AD_STATE_UNAVAILABLE = 0; + /** State for an ad that has a URL but has not yet been played. */ + public static final int AD_STATE_AVAILABLE = 1; + /** State for an ad that was skipped. */ + public static final int AD_STATE_SKIPPED = 2; + /** State for an ad that was played in full. */ + public static final int AD_STATE_PLAYED = 3; + /** State for an ad that could not be loaded. */ + public static final int AD_STATE_ERROR = 4; + + /** Ad playback state with no ads. */ + public static final AdPlaybackState NONE = new AdPlaybackState(); + + /** The number of ad groups. */ + public final int adGroupCount; + /** + * The times of ad groups, in microseconds. A final element with the value {@link + * C#TIME_END_OF_SOURCE} indicates a postroll ad. + */ + public final long[] adGroupTimesUs; + /** The ad groups. */ + public final AdGroup[] adGroups; + /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ + public final long adResumePositionUs; + /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + public final long contentDurationUs; + + /** + * Creates a new ad playback state with the specified ad group times. + * + * @param adGroupTimesUs The times of ad groups in microseconds. A final element with the value + * {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. + */ + public AdPlaybackState(long... adGroupTimesUs) { + int count = adGroupTimesUs.length; + adGroupCount = count; + this.adGroupTimesUs = Arrays.copyOf(adGroupTimesUs, count); + this.adGroups = new AdGroup[count]; + for (int i = 0; i < count; i++) { + adGroups[i] = new AdGroup(); + } + adResumePositionUs = 0; + contentDurationUs = C.TIME_UNSET; + } + + private AdPlaybackState( + long[] adGroupTimesUs, AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) { + adGroupCount = adGroups.length; + this.adGroupTimesUs = adGroupTimesUs; + this.adGroups = adGroups; + this.adResumePositionUs = adResumePositionUs; + this.contentDurationUs = contentDurationUs; + } + + /** + * Returns the index of the ad group at or before {@code positionUs}, if that ad group is + * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has no + * ads remaining to be played, or if there is no such ad group. + * + * @param positionUs The position at or before which to find an ad group, in microseconds, or + * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any + * unplayed postroll ad group will be returned). + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexForPositionUs(long positionUs) { + // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. + // In practice we expect there to be few ad groups so the search shouldn't be expensive. + int index = adGroupTimesUs.length - 1; + while (index >= 0 && isPositionBeforeAdGroup(positionUs, index)) { + index--; + } + return index >= 0 && adGroups[index].hasUnplayedAds() ? index : C.INDEX_UNSET; + } + + /** + * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be + * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. + * + * @param positionUs The position after which to find an ad group, in microseconds, or {@link + * C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad group + * after the position). + * @param periodDurationUs The duration of the containing period in microseconds, or {@link + * C#TIME_UNSET} if not known. + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexAfterPositionUs(long positionUs, long periodDurationUs) { + if (positionUs == C.TIME_END_OF_SOURCE + || (periodDurationUs != C.TIME_UNSET && positionUs >= periodDurationUs)) { + return C.INDEX_UNSET; + } + // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. + // In practice we expect there to be few ad groups so the search shouldn't be expensive. + int index = 0; + while (index < adGroupTimesUs.length + && adGroupTimesUs[index] != C.TIME_END_OF_SOURCE + && (positionUs >= adGroupTimesUs[index] || !adGroups[index].hasUnplayedAds())) { + index++; + } + return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; + } + + /** + * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. + * The ad count must be greater than zero. + */ + @CheckResult + public AdPlaybackState withAdCount(int adGroupIndex, int adCount) { + Assertions.checkArgument(adCount > 0); + if (adGroups[adGroupIndex].count == adCount) { + return this; + } + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = this.adGroups[adGroupIndex].withAdCount(adCount); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad URI. */ + @CheckResult + public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdUri(uri, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as played. */ + @CheckResult + public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_PLAYED, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as skipped. */ + @CheckResult + public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_SKIPPED, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as having a load error. */ + @CheckResult + public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_ERROR, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** + * Returns an instance with all ads in the specified ad group skipped (except for those already + * marked as played or in the error state). + */ + @CheckResult + public AdPlaybackState withSkippedAdGroup(int adGroupIndex) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAllAdsSkipped(); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdPlaybackState withAdDurationsUs(long[][] adDurationUs) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + for (int adGroupIndex = 0; adGroupIndex < adGroupCount; adGroupIndex++) { + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdDurationsUs(adDurationUs[adGroupIndex]); + } + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad resume position, in microseconds. */ + @CheckResult + public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { + if (this.adResumePositionUs == adResumePositionUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + } + + /** Returns an instance with the specified content duration, in microseconds. */ + @CheckResult + public AdPlaybackState withContentDurationUs(long contentDurationUs) { + if (this.contentDurationUs == contentDurationUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdPlaybackState that = (AdPlaybackState) o; + return adGroupCount == that.adGroupCount + && adResumePositionUs == that.adResumePositionUs + && contentDurationUs == that.contentDurationUs + && Arrays.equals(adGroupTimesUs, that.adGroupTimesUs) + && Arrays.equals(adGroups, that.adGroups); + } + + @Override + public int hashCode() { + int result = adGroupCount; + result = 31 * result + (int) adResumePositionUs; + result = 31 * result + (int) contentDurationUs; + result = 31 * result + Arrays.hashCode(adGroupTimesUs); + result = 31 * result + Arrays.hashCode(adGroups); + return result; + } + + private boolean isPositionBeforeAdGroup(long positionUs, int adGroupIndex) { + if (positionUs == C.TIME_END_OF_SOURCE) { + // The end of the content is at (but not before) any postroll ad, and after any other ads. + return false; + } + long adGroupPositionUs = adGroupTimesUs[adGroupIndex]; + if (adGroupPositionUs == C.TIME_END_OF_SOURCE) { + return contentDurationUs == C.TIME_UNSET || positionUs < contentDurationUs; + } else { + return positionUs < adGroupPositionUs; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java new file mode 100644 index 0000000000..12ffb8ec0d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; + +/** + * Interface for loaders of ads, which can be used with {@link AdsMediaSource}. + * + * <p>Ads loaders notify the {@link AdsMediaSource} about events via {@link EventListener}. In + * particular, implementations must call {@link EventListener#onAdPlaybackState(AdPlaybackState)} + * with a new copy of the current {@link AdPlaybackState} whenever further information about ads + * becomes known (for example, when an ad media URI is available, or an ad has played to the end). + * + * <p>{@link #start(EventListener, AdViewProvider)} will be called when the ads media source first + * initializes, at which point the loader can request ads. If the player enters the background, + * {@link #stop()} will be called. Loaders should maintain any ad playback state in preparation for + * a later call to {@link #start(EventListener, AdViewProvider)}. If an ad is playing when the + * player is detached, update the ad playback state with the current playback position using {@link + * AdPlaybackState#withAdResumePositionUs(long)}. + * + * <p>If {@link EventListener#onAdPlaybackState(AdPlaybackState)} has been called, the + * implementation of {@link #start(EventListener, AdViewProvider)} should invoke the same listener + * to provide the existing playback state to the new player. + */ +public interface AdsLoader { + + /** Listener for ads loader events. All methods are called on the main thread. */ + interface EventListener { + + /** + * Called when the ad playback state has been updated. + * + * @param adPlaybackState The new ad playback state. + */ + default void onAdPlaybackState(AdPlaybackState adPlaybackState) {} + + /** + * Called when there was an error loading ads. + * + * @param error The error. + * @param dataSpec The data spec associated with the load error. + */ + default void onAdLoadError(AdLoadException error, DataSpec dataSpec) {} + + /** Called when the user clicks through an ad (for example, following a 'learn more' link). */ + default void onAdClicked() {} + + /** Called when the user taps a non-clickthrough part of an ad. */ + default void onAdTapped() {} + } + + /** Provides views for the ad UI. */ + interface AdViewProvider { + + /** Returns the {@link ViewGroup} on top of the player that will show any ad UI. */ + ViewGroup getAdViewGroup(); + + /** + * Returns an array of views that are shown on top of the ad view group, but that are essential + * for controlling playback and should be excluded from ad viewability measurements by the + * {@link AdsLoader} (if it supports this). + * + * <p>Each view must be either a fully transparent overlay (for capturing touch events), or a + * small piece of transient UI that is essential to the user experience of playback (such as a + * button to pause/resume playback or a transient full-screen or cast button). For more + * information see the documentation for your ads loader. + */ + View[] getAdOverlayViews(); + } + + // Methods called by the application. + + /** + * Sets the player that will play the loaded ads. + * + * <p>This method must be called before the player is prepared with media using this ads loader. + * + * <p>This method must also be called on the main thread and only players which are accessed on + * the main thread are supported ({@code player.getApplicationLooper() == + * Looper.getMainLooper()}). + * + * @param player The player instance that will play the loaded ads. May be null to delete the + * reference to a previously set player. + */ + void setPlayer(@Nullable Player player); + + /** + * Releases the loader. Must be called by the application on the main thread when the instance is + * no longer needed. + */ + void release(); + + // Methods called by AdsMediaSource. + + /** + * Sets the supported content types for ad media. Must be called before the first call to {@link + * #start(EventListener, AdViewProvider)}. Subsequent calls may be ignored. Called on the main + * thread by {@link AdsMediaSource}. + * + * @param contentTypes The supported content types for ad media. Each element must be one of + * {@link C#TYPE_DASH}, {@link C#TYPE_HLS}, {@link C#TYPE_SS} and {@link C#TYPE_OTHER}. + */ + void setSupportedContentTypes(@C.ContentType int... contentTypes); + + /** + * Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}. + * + * @param eventListener Listener for ads loader events. + * @param adViewProvider Provider of views for the ad UI. + */ + void start(EventListener eventListener, AdViewProvider adViewProvider); + + /** + * Stops using the ads loader for playback and deregisters the event listener. Called on the main + * thread by {@link AdsMediaSource}. + */ + void stop(); + + /** + * Notifies the ads loader that the player was not able to prepare media for a given ad. + * Implementations should update the ad playback state as the specified ad has failed to load. + * Called on the main thread by {@link AdsMediaSource}. + * + * @param adGroupIndex The index of the ad group. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param exception The preparation error. + */ + void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java new file mode 100644 index 0000000000..02c33a3d34 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MaskingMediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ProgressiveMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A {@link MediaSource} that inserts ads linearly with a provided content media source. This source + * cannot be used as a child source in a composition. It must be the top-level source used to + * prepare the player. + */ +public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> { + + /** + * Wrapper for exceptions that occur while loading ads, which are notified via {@link + * MediaSourceEventListener#onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, + * IOException, boolean)}. + */ + public static final class AdLoadException extends IOException { + + /** + * Types of ad load exceptions. One of {@link #TYPE_AD}, {@link #TYPE_AD_GROUP}, {@link + * #TYPE_ALL_ADS} or {@link #TYPE_UNEXPECTED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_AD, TYPE_AD_GROUP, TYPE_ALL_ADS, TYPE_UNEXPECTED}) + public @interface Type {} + /** Type for when an ad failed to load. The ad will be skipped. */ + public static final int TYPE_AD = 0; + /** Type for when an ad group failed to load. The ad group will be skipped. */ + public static final int TYPE_AD_GROUP = 1; + /** Type for when all ad groups failed to load. All ads will be skipped. */ + public static final int TYPE_ALL_ADS = 2; + /** Type for when an unexpected error occurred while loading ads. All ads will be skipped. */ + public static final int TYPE_UNEXPECTED = 3; + + /** Returns a new ad load exception of {@link #TYPE_AD}. */ + public static AdLoadException createForAd(Exception error) { + return new AdLoadException(TYPE_AD, error); + } + + /** Returns a new ad load exception of {@link #TYPE_AD_GROUP}. */ + public static AdLoadException createForAdGroup(Exception error, int adGroupIndex) { + return new AdLoadException( + TYPE_AD_GROUP, new IOException("Failed to load ad group " + adGroupIndex, error)); + } + + /** Returns a new ad load exception of {@link #TYPE_ALL_ADS}. */ + public static AdLoadException createForAllAds(Exception error) { + return new AdLoadException(TYPE_ALL_ADS, error); + } + + /** Returns a new ad load exception of {@link #TYPE_UNEXPECTED}. */ + public static AdLoadException createForUnexpected(RuntimeException error) { + return new AdLoadException(TYPE_UNEXPECTED, error); + } + + /** The {@link Type} of the ad load exception. */ + public final @Type int type; + + private AdLoadException(@Type int type, Exception cause) { + super(cause); + this.type = type; + } + + /** + * Returns the {@link RuntimeException} that caused the exception if its type is {@link + * #TYPE_UNEXPECTED}. + */ + public RuntimeException getRuntimeExceptionForUnexpected() { + Assertions.checkState(type == TYPE_UNEXPECTED); + return (RuntimeException) Assertions.checkNotNull(getCause()); + } + } + + // Used to identify the content "child" source for CompositeMediaSource. + private static final MediaPeriodId DUMMY_CONTENT_MEDIA_PERIOD_ID = + new MediaPeriodId(/* periodUid= */ new Object()); + + private final MediaSource contentMediaSource; + private final MediaSourceFactory adMediaSourceFactory; + private final AdsLoader adsLoader; + private final AdsLoader.AdViewProvider adViewProvider; + private final Handler mainHandler; + private final Map<MediaSource, List<MaskingMediaPeriod>> maskingMediaPeriodByAdMediaSource; + private final Timeline.Period period; + + // Accessed on the player thread. + @Nullable private ComponentListener componentListener; + @Nullable private Timeline contentTimeline; + @Nullable private AdPlaybackState adPlaybackState; + private @NullableType MediaSource[][] adGroupMediaSources; + private @NullableType Timeline[][] adGroupTimelines; + + /** + * Constructs a new source that inserts ads linearly with the content specified by {@code + * contentMediaSource}. Ad media is loaded using {@link ProgressiveMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param dataSourceFactory Factory for data sources used to load ad media. + * @param adsLoader The loader for ads. + * @param adViewProvider Provider of views for the ad UI. + */ + public AdsMediaSource( + MediaSource contentMediaSource, + DataSource.Factory dataSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider) { + this( + contentMediaSource, + new ProgressiveMediaSource.Factory(dataSourceFactory), + adsLoader, + adViewProvider); + } + + /** + * Constructs a new source that inserts ads linearly with the content specified by {@code + * contentMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param adMediaSourceFactory Factory for media sources used to load ad media. + * @param adsLoader The loader for ads. + * @param adViewProvider Provider of views for the ad UI. + */ + public AdsMediaSource( + MediaSource contentMediaSource, + MediaSourceFactory adMediaSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider) { + this.contentMediaSource = contentMediaSource; + this.adMediaSourceFactory = adMediaSourceFactory; + this.adsLoader = adsLoader; + this.adViewProvider = adViewProvider; + mainHandler = new Handler(Looper.getMainLooper()); + maskingMediaPeriodByAdMediaSource = new HashMap<>(); + period = new Timeline.Period(); + adGroupMediaSources = new MediaSource[0][]; + adGroupTimelines = new Timeline[0][]; + adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); + } + + @Override + @Nullable + public Object getTag() { + return contentMediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + ComponentListener componentListener = new ComponentListener(); + this.componentListener = componentListener; + prepareChildSource(DUMMY_CONTENT_MEDIA_PERIOD_ID, contentMediaSource); + mainHandler.post(() -> adsLoader.start(componentListener, adViewProvider)); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + AdPlaybackState adPlaybackState = Assertions.checkNotNull(this.adPlaybackState); + if (adPlaybackState.adGroupCount > 0 && id.isAd()) { + int adGroupIndex = id.adGroupIndex; + int adIndexInAdGroup = id.adIndexInAdGroup; + Uri adUri = + Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]); + if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { + int adCount = adIndexInAdGroup + 1; + adGroupMediaSources[adGroupIndex] = + Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount); + adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount); + } + MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; + if (mediaSource == null) { + mediaSource = adMediaSourceFactory.createMediaSource(adUri); + adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource; + maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>()); + prepareChildSource(id, mediaSource); + } + MaskingMediaPeriod maskingMediaPeriod = + new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); + maskingMediaPeriod.setPrepareErrorListener( + new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); + List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource); + if (mediaPeriods == null) { + Object periodUid = + Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup]) + .getUidOfPeriod(/* periodIndex= */ 0); + MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); + maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); + } else { + // Keep track of the masking media period so it can be populated with the real media period + // when the source's info becomes available. + mediaPeriods.add(maskingMediaPeriod); + } + return maskingMediaPeriod; + } else { + MaskingMediaPeriod mediaPeriod = + new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs); + mediaPeriod.createPeriod(id); + return mediaPeriod; + } + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod; + List<MaskingMediaPeriod> mediaPeriods = + maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource); + if (mediaPeriods != null) { + mediaPeriods.remove(maskingMediaPeriod); + } + maskingMediaPeriod.releasePeriod(); + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + Assertions.checkNotNull(componentListener).release(); + componentListener = null; + maskingMediaPeriodByAdMediaSource.clear(); + contentTimeline = null; + adPlaybackState = null; + adGroupMediaSources = new MediaSource[0][]; + adGroupTimelines = new Timeline[0][]; + mainHandler.post(adsLoader::stop); + } + + @Override + protected void onChildSourceInfoRefreshed( + MediaPeriodId mediaPeriodId, MediaSource mediaSource, Timeline timeline) { + if (mediaPeriodId.isAd()) { + int adGroupIndex = mediaPeriodId.adGroupIndex; + int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup; + onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline); + } else { + onContentSourceInfoRefreshed(timeline); + } + } + + @Override + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + MediaPeriodId childId, MediaPeriodId mediaPeriodId) { + // The child id for the content period is just DUMMY_CONTENT_MEDIA_PERIOD_ID. That's why we need + // to forward the reported mediaPeriodId in this case. + return childId.isAd() ? childId : mediaPeriodId; + } + + // Internal methods. + + private void onAdPlaybackState(AdPlaybackState adPlaybackState) { + if (this.adPlaybackState == null) { + adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][]; + Arrays.fill(adGroupMediaSources, new MediaSource[0]); + adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][]; + Arrays.fill(adGroupTimelines, new Timeline[0]); + } + this.adPlaybackState = adPlaybackState; + maybeUpdateSourceInfo(); + } + + private void onContentSourceInfoRefreshed(Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + contentTimeline = timeline; + maybeUpdateSourceInfo(); + } + + private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex, + int adIndexInAdGroup, Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline; + List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource); + if (mediaPeriods != null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + for (int i = 0; i < mediaPeriods.size(); i++) { + MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i); + MediaPeriodId adSourceMediaPeriodId = + new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); + mediaPeriod.createPeriod(adSourceMediaPeriodId); + } + } + maybeUpdateSourceInfo(); + } + + private void maybeUpdateSourceInfo() { + Timeline contentTimeline = this.contentTimeline; + if (adPlaybackState != null && contentTimeline != null) { + adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period)); + Timeline timeline = + adPlaybackState.adGroupCount == 0 + ? contentTimeline + : new SinglePeriodAdTimeline(contentTimeline, adPlaybackState); + refreshSourceInfo(timeline); + } + } + + private static long[][] getAdDurations( + @NullableType Timeline[][] adTimelines, Timeline.Period period) { + long[][] adDurations = new long[adTimelines.length][]; + for (int i = 0; i < adTimelines.length; i++) { + adDurations[i] = new long[adTimelines[i].length]; + for (int j = 0; j < adTimelines[i].length; j++) { + adDurations[i][j] = + adTimelines[i][j] == null + ? C.TIME_UNSET + : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + } + } + return adDurations; + } + + /** Listener for component events. All methods are called on the main thread. */ + private final class ComponentListener implements AdsLoader.EventListener { + + private final Handler playerHandler; + + private volatile boolean released; + + /** + * Creates new listener which forwards ad playback states on the creating thread and all other + * events on the external event listener thread. + */ + public ComponentListener() { + playerHandler = new Handler(); + } + + /** Releases the component listener. */ + public void release() { + released = true; + playerHandler.removeCallbacksAndMessages(null); + } + + @Override + public void onAdPlaybackState(final AdPlaybackState adPlaybackState) { + if (released) { + return; + } + playerHandler.post( + () -> { + if (released) { + return; + } + AdsMediaSource.this.onAdPlaybackState(adPlaybackState); + }); + } + + @Override + public void onAdLoadError(final AdLoadException error, DataSpec dataSpec) { + if (released) { + return; + } + createEventDispatcher(/* mediaPeriodId= */ null) + .loadError( + dataSpec, + dataSpec.uri, + /* responseHeaders= */ Collections.emptyMap(), + C.DATA_TYPE_AD, + C.TRACK_TYPE_UNKNOWN, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0, + error, + /* wasCanceled= */ true); + } + } + + private final class AdPrepareErrorListener implements MaskingMediaPeriod.PrepareErrorListener { + + private final Uri adUri; + private final int adGroupIndex; + private final int adIndexInAdGroup; + + public AdPrepareErrorListener(Uri adUri, int adGroupIndex, int adIndexInAdGroup) { + this.adUri = adUri; + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public void onPrepareError(MediaPeriodId mediaPeriodId, final IOException exception) { + createEventDispatcher(mediaPeriodId) + .loadError( + new DataSpec(adUri), + adUri, + /* responseHeaders= */ Collections.emptyMap(), + C.DATA_TYPE_AD, + C.TRACK_TYPE_UNKNOWN, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0, + AdLoadException.createForAd(exception), + /* wasCanceled= */ true); + mainHandler.post( + () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java new file mode 100644 index 0000000000..44f6d0bc66 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ForwardingTimeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** A {@link Timeline} for sources that have ads. */ +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public final class SinglePeriodAdTimeline extends ForwardingTimeline { + + private final AdPlaybackState adPlaybackState; + + /** + * Creates a new timeline with a single period containing ads. + * + * @param contentTimeline The timeline of the content alongside which ads will be played. It must + * have one window and one period. + * @param adPlaybackState The state of the period's ads. + */ + public SinglePeriodAdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) { + super(contentTimeline); + Assertions.checkState(contentTimeline.getPeriodCount() == 1); + Assertions.checkState(contentTimeline.getWindowCount() == 1); + this.adPlaybackState = adPlaybackState; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(periodIndex, period, setIds); + period.set( + period.id, + period.uid, + period.windowIndex, + period.durationUs, + period.getPositionInWindowUs(), + adPlaybackState); + return period; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + window = super.getWindow(windowIndex, window, defaultPositionProjectionUs); + if (window.durationUs == C.TIME_UNSET) { + window.durationUs = adPlaybackState.contentDurationUs; + } + return window; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java new file mode 100644 index 0000000000..406cd1617a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; + +/** + * A base implementation of {@link MediaChunk} that outputs to a {@link BaseMediaChunkOutput}. + */ +public abstract class BaseMediaChunk extends MediaChunk { + + /** + * The time from which output will begin, or {@link C#TIME_UNSET} if output will begin from the + * start of the chunk. + */ + public final long clippedStartTimeUs; + /** + * The time from which output will end, or {@link C#TIME_UNSET} if output will end at the end of + * the chunk. + */ + public final long clippedEndTimeUs; + + private BaseMediaChunkOutput output; + private int[] firstSampleIndices; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param clippedStartTimeUs The time in the chunk from which output will begin, or {@link + * C#TIME_UNSET} to output from the start of the chunk. + * @param clippedEndTimeUs The time in the chunk from which output will end, or {@link + * C#TIME_UNSET} to output to the end of the chunk. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + */ + public BaseMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long clippedStartTimeUs, + long clippedEndTimeUs, + long chunkIndex) { + super(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, startTimeUs, + endTimeUs, chunkIndex); + this.clippedStartTimeUs = clippedStartTimeUs; + this.clippedEndTimeUs = clippedEndTimeUs; + } + + /** + * Initializes the chunk for loading, setting the {@link BaseMediaChunkOutput} that will receive + * samples as they are loaded. + * + * @param output The output that will receive the loaded media samples. + */ + public void init(BaseMediaChunkOutput output) { + this.output = output; + firstSampleIndices = output.getWriteIndices(); + } + + /** + * Returns the index of the first sample in the specified track of the output that will originate + * from this chunk. + */ + public final int getFirstSampleIndex(int trackIndex) { + return firstSampleIndices[trackIndex]; + } + + /** + * Returns the output most recently passed to {@link #init(BaseMediaChunkOutput)}. + */ + protected final BaseMediaChunkOutput getOutput() { + return output; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java new file mode 100644 index 0000000000..3987260578 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import java.util.NoSuchElementException; + +/** + * Base class for {@link MediaChunkIterator}s. Handles {@link #next()} and {@link #isEnded()}, and + * provides a bounds check for child classes. + */ +public abstract class BaseMediaChunkIterator implements MediaChunkIterator { + + private final long fromIndex; + private final long toIndex; + + private long currentIndex; + + /** + * Creates base iterator. + * + * @param fromIndex The first available index. + * @param toIndex The last available index. + */ + @SuppressWarnings("method.invocation.invalid") + public BaseMediaChunkIterator(long fromIndex, long toIndex) { + this.fromIndex = fromIndex; + this.toIndex = toIndex; + reset(); + } + + @Override + public boolean isEnded() { + return currentIndex > toIndex; + } + + @Override + public boolean next() { + currentIndex++; + return !isEnded(); + } + + @Override + public void reset() { + currentIndex = fromIndex - 1; + } + + /** + * Verifies that the iterator points to a valid element. + * + * @throws NoSuchElementException If the iterator does not point to a valid element. + */ + protected final void checkInBounds() { + if (currentIndex < fromIndex || currentIndex > toIndex) { + throw new NoSuchElementException(); + } + } + + /** Returns the current index this iterator is pointing to. */ + protected final long getCurrentIndex() { + return currentIndex; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java new file mode 100644 index 0000000000..5d1f93bf01 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * A {@link TrackOutputProvider} that provides {@link TrackOutput TrackOutputs} based on a + * predefined mapping from track type to output. + */ +public final class BaseMediaChunkOutput implements TrackOutputProvider { + + private static final String TAG = "BaseMediaChunkOutput"; + + private final int[] trackTypes; + private final SampleQueue[] sampleQueues; + + /** + * @param trackTypes The track types of the individual track outputs. + * @param sampleQueues The individual sample queues. + */ + public BaseMediaChunkOutput(int[] trackTypes, SampleQueue[] sampleQueues) { + this.trackTypes = trackTypes; + this.sampleQueues = sampleQueues; + } + + @Override + public TrackOutput track(int id, int type) { + for (int i = 0; i < trackTypes.length; i++) { + if (type == trackTypes[i]) { + return sampleQueues[i]; + } + } + Log.e(TAG, "Unmatched track of type: " + type); + return new DummyTrackOutput(); + } + + /** + * Returns the current absolute write indices of the individual sample queues. + */ + public int[] getWriteIndices() { + int[] writeIndices = new int[sampleQueues.length]; + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueues[i] != null) { + writeIndices[i] = sampleQueues[i].getWriteIndex(); + } + } + return writeIndices; + } + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples + * subsequently written to the sample queues. + */ + public void setSampleOffsetUs(long sampleOffsetUs) { + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue != null) { + sampleQueue.setSampleOffsetUs(sampleOffsetUs); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java new file mode 100644 index 0000000000..3f4450eddd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.List; +import java.util.Map; + +/** + * An abstract base class for {@link Loadable} implementations that load chunks of data required + * for the playback of streams. + */ +public abstract class Chunk implements Loadable { + + /** + * The {@link DataSpec} that defines the data to be loaded. + */ + public final DataSpec dataSpec; + /** + * The type of the chunk. One of the {@code DATA_TYPE_*} constants defined in {@link C}. For + * reporting only. + */ + public final int type; + /** + * The format of the track to which this chunk belongs, or null if the chunk does not belong to + * a track. + */ + public final Format trackFormat; + /** + * One of the {@link C} {@code SELECTION_REASON_*} constants if the chunk belongs to a track. + * {@link C#SELECTION_REASON_UNKNOWN} if the chunk does not belong to a track. + */ + public final int trackSelectionReason; + /** + * Optional data associated with the selection of the track to which this chunk belongs. Null if + * the chunk does not belong to a track. + */ + @Nullable public final Object trackSelectionData; + /** + * The start time of the media contained by the chunk, or {@link C#TIME_UNSET} if the data + * being loaded does not contain media samples. + */ + public final long startTimeUs; + /** + * The end time of the media contained by the chunk, or {@link C#TIME_UNSET} if the data being + * loaded does not contain media samples. + */ + public final long endTimeUs; + + protected final StatsDataSource dataSource; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param type See {@link #type}. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs See {@link #startTimeUs}. + * @param endTimeUs See {@link #endTimeUs}. + */ + public Chunk( + DataSource dataSource, + DataSpec dataSpec, + int type, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs) { + this.dataSource = new StatsDataSource(dataSource); + this.dataSpec = Assertions.checkNotNull(dataSpec); + this.type = type; + this.trackFormat = trackFormat; + this.trackSelectionReason = trackSelectionReason; + this.trackSelectionData = trackSelectionData; + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + } + + /** + * Returns the duration of the chunk in microseconds. + */ + public final long getDurationUs() { + return endTimeUs - startTimeUs; + } + + /** + * Returns the number of bytes that have been loaded. Must only be called after the load + * completed, failed, or was canceled. + */ + public final long bytesLoaded() { + return dataSource.getBytesRead(); + } + + /** + * Returns the {@link Uri} associated with the last {@link DataSource#open} call. If redirection + * occurred, this is the redirected uri. Must only be called after the load completed, failed, or + * was canceled. + * + * @see DataSource#getUri() + */ + public final Uri getUri() { + return dataSource.getLastOpenedUri(); + } + + /** + * Returns the response headers associated with the last {@link DataSource#open} call. Must only + * be called after the load completed, failed, or was canceled. + * + * @see DataSource#getResponseHeaders() + */ + public final Map<String, List<String>> getResponseHeaders() { + return dataSource.getLastResponseHeaders(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java new file mode 100644 index 0000000000..04cef9198c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import android.util.SparseArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * An {@link Extractor} wrapper for loading chunks that contain a single primary track, and possibly + * additional embedded tracks. + * <p> + * The wrapper allows switching of the {@link TrackOutput}s that receive parsed data. + */ +public final class ChunkExtractorWrapper implements ExtractorOutput { + + /** + * Provides {@link TrackOutput} instances to be written to by the wrapper. + */ + public interface TrackOutputProvider { + + /** + * Called to get the {@link TrackOutput} for a specific track. + * <p> + * The same {@link TrackOutput} is returned if multiple calls are made with the same {@code id}. + * + * @param id A track identifier. + * @param type The type of the track. Typically one of the + * {@link org.mozilla.thirdparty.com.google.android.exoplayer2C} {@code TRACK_TYPE_*} constants. + * @return The {@link TrackOutput} for the given track identifier. + */ + TrackOutput track(int id, int type); + + } + + public final Extractor extractor; + + private final int primaryTrackType; + private final Format primaryTrackManifestFormat; + private final SparseArray<BindingTrackOutput> bindingTrackOutputs; + + private boolean extractorInitialized; + private TrackOutputProvider trackOutputProvider; + private long endTimeUs; + private SeekMap seekMap; + private Format[] sampleFormats; + + /** + * @param extractor The extractor to wrap. + * @param primaryTrackType The type of the primary track. Typically one of the + * {@link org.mozilla.thirdparty.com.google.android.exoplayer2C} {@code TRACK_TYPE_*} constants. + * @param primaryTrackManifestFormat A manifest defined {@link Format} whose data should be merged + * into any sample {@link Format} output from the {@link Extractor} for the primary track. + */ + public ChunkExtractorWrapper(Extractor extractor, int primaryTrackType, + Format primaryTrackManifestFormat) { + this.extractor = extractor; + this.primaryTrackType = primaryTrackType; + this.primaryTrackManifestFormat = primaryTrackManifestFormat; + bindingTrackOutputs = new SparseArray<>(); + } + + /** + * Returns the {@link SeekMap} most recently output by the extractor, or null. + */ + public SeekMap getSeekMap() { + return seekMap; + } + + /** + * Returns the sample {@link Format}s most recently output by the extractor, or null. + */ + public Format[] getSampleFormats() { + return sampleFormats; + } + + /** + * Initializes the wrapper to output to {@link TrackOutput}s provided by the specified {@link + * TrackOutputProvider}, and configures the extractor to receive data from a new chunk. + * + * @param trackOutputProvider The provider of {@link TrackOutput}s that will receive sample data. + * @param startTimeUs The start position in the new chunk, or {@link C#TIME_UNSET} to output + * samples from the start of the chunk. + * @param endTimeUs The end position in the new chunk, or {@link C#TIME_UNSET} to output samples + * to the end of the chunk. + */ + public void init( + @Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs) { + this.trackOutputProvider = trackOutputProvider; + this.endTimeUs = endTimeUs; + if (!extractorInitialized) { + extractor.init(this); + if (startTimeUs != C.TIME_UNSET) { + extractor.seek(/* position= */ 0, startTimeUs); + } + extractorInitialized = true; + } else { + extractor.seek(/* position= */ 0, startTimeUs == C.TIME_UNSET ? 0 : startTimeUs); + for (int i = 0; i < bindingTrackOutputs.size(); i++) { + bindingTrackOutputs.valueAt(i).bind(trackOutputProvider, endTimeUs); + } + } + } + + // ExtractorOutput implementation. + + @Override + public TrackOutput track(int id, int type) { + BindingTrackOutput bindingTrackOutput = bindingTrackOutputs.get(id); + if (bindingTrackOutput == null) { + // Assert that if we're seeing a new track we have not seen endTracks. + Assertions.checkState(sampleFormats == null); + // TODO: Manifest formats for embedded tracks should also be passed here. + bindingTrackOutput = new BindingTrackOutput(id, type, + type == primaryTrackType ? primaryTrackManifestFormat : null); + bindingTrackOutput.bind(trackOutputProvider, endTimeUs); + bindingTrackOutputs.put(id, bindingTrackOutput); + } + return bindingTrackOutput; + } + + @Override + public void endTracks() { + Format[] sampleFormats = new Format[bindingTrackOutputs.size()]; + for (int i = 0; i < bindingTrackOutputs.size(); i++) { + sampleFormats[i] = bindingTrackOutputs.valueAt(i).sampleFormat; + } + this.sampleFormats = sampleFormats; + } + + @Override + public void seekMap(SeekMap seekMap) { + this.seekMap = seekMap; + } + + // Internal logic. + + private static final class BindingTrackOutput implements TrackOutput { + + private final int id; + private final int type; + private final Format manifestFormat; + private final DummyTrackOutput dummyTrackOutput; + + public Format sampleFormat; + private TrackOutput trackOutput; + private long endTimeUs; + + public BindingTrackOutput(int id, int type, Format manifestFormat) { + this.id = id; + this.type = type; + this.manifestFormat = manifestFormat; + dummyTrackOutput = new DummyTrackOutput(); + } + + public void bind(TrackOutputProvider trackOutputProvider, long endTimeUs) { + if (trackOutputProvider == null) { + trackOutput = dummyTrackOutput; + return; + } + this.endTimeUs = endTimeUs; + trackOutput = trackOutputProvider.track(id, type); + if (sampleFormat != null) { + trackOutput.format(sampleFormat); + } + } + + @Override + public void format(Format format) { + sampleFormat = manifestFormat != null ? format.copyWithManifestFormatInfo(manifestFormat) + : format; + trackOutput.format(sampleFormat); + } + + @Override + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + return trackOutput.sampleData(input, length, allowEndOfInput); + } + + @Override + public void sampleData(ParsableByteArray data, int length) { + trackOutput.sampleData(data, length); + } + + @Override + public void sampleMetadata(long timeUs, @C.BufferFlags int flags, int size, int offset, + CryptoData cryptoData) { + if (endTimeUs != C.TIME_UNSET && timeUs >= endTimeUs) { + trackOutput = dummyTrackOutput; + } + trackOutput.sampleMetadata(timeUs, flags, size, offset, cryptoData); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java new file mode 100644 index 0000000000..ef9daddd2c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; + +/** + * Holds a chunk or an indication that the end of the stream has been reached. + */ +public final class ChunkHolder { + + /** The chunk. */ + @Nullable public Chunk chunk; + + /** + * Indicates that the end of the stream has been reached. + */ + public boolean endOfStream; + + /** + * Clears the holder. + */ + public void clear() { + chunk = null; + endOfStream = false; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java new file mode 100644 index 0000000000..a789805cd7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -0,0 +1,791 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A {@link SampleStream} that loads media in {@link Chunk}s, obtained from a {@link ChunkSource}. + * May also be configured to expose additional embedded {@link SampleStream}s. + */ +public class ChunkSampleStream<T extends ChunkSource> implements SampleStream, SequenceableLoader, + Loader.Callback<Chunk>, Loader.ReleaseCallback { + + /** A callback to be notified when a sample stream has finished being released. */ + public interface ReleaseCallback<T extends ChunkSource> { + + /** + * Called when the {@link ChunkSampleStream} has finished being released. + * + * @param chunkSampleStream The released sample stream. + */ + void onSampleStreamReleased(ChunkSampleStream<T> chunkSampleStream); + } + + private static final String TAG = "ChunkSampleStream"; + + public final int primaryTrackType; + + @Nullable private final int[] embeddedTrackTypes; + @Nullable private final Format[] embeddedTrackFormats; + private final boolean[] embeddedTracksSelected; + private final T chunkSource; + private final SequenceableLoader.Callback<ChunkSampleStream<T>> callback; + private final EventDispatcher eventDispatcher; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final Loader loader; + private final ChunkHolder nextChunkHolder; + private final ArrayList<BaseMediaChunk> mediaChunks; + private final List<BaseMediaChunk> readOnlyMediaChunks; + private final SampleQueue primarySampleQueue; + private final SampleQueue[] embeddedSampleQueues; + private final BaseMediaChunkOutput chunkOutput; + + private Format primaryDownstreamTrackFormat; + @Nullable private ReleaseCallback<T> releaseCallback; + private long pendingResetPositionUs; + private long lastSeekPositionUs; + private int nextNotifyPrimaryFormatMediaChunkIndex; + + /* package */ long decodeOnlyUntilPositionUs; + /* package */ boolean loadingFinished; + + /** + * Constructs an instance. + * + * @param primaryTrackType The type of the primary track. One of the {@link C} {@code + * TRACK_TYPE_*} constants. + * @param embeddedTrackTypes The types of any embedded tracks, or null. + * @param embeddedTrackFormats The formats of the embedded tracks, or null. + * @param chunkSource A {@link ChunkSource} from which chunks to load are obtained. + * @param callback An {@link Callback} for the stream. + * @param allocator An {@link Allocator} from which allocations can be obtained. + * @param positionUs The position from which to start loading media. + * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} + * from. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + */ + public ChunkSampleStream( + int primaryTrackType, + @Nullable int[] embeddedTrackTypes, + @Nullable Format[] embeddedTrackFormats, + T chunkSource, + Callback<ChunkSampleStream<T>> callback, + Allocator allocator, + long positionUs, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher) { + this.primaryTrackType = primaryTrackType; + this.embeddedTrackTypes = embeddedTrackTypes; + this.embeddedTrackFormats = embeddedTrackFormats; + this.chunkSource = chunkSource; + this.callback = callback; + this.eventDispatcher = eventDispatcher; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + loader = new Loader("Loader:ChunkSampleStream"); + nextChunkHolder = new ChunkHolder(); + mediaChunks = new ArrayList<>(); + readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks); + + int embeddedTrackCount = embeddedTrackTypes == null ? 0 : embeddedTrackTypes.length; + embeddedSampleQueues = new SampleQueue[embeddedTrackCount]; + embeddedTracksSelected = new boolean[embeddedTrackCount]; + int[] trackTypes = new int[1 + embeddedTrackCount]; + SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount]; + + primarySampleQueue = new SampleQueue(allocator, drmSessionManager); + trackTypes[0] = primaryTrackType; + sampleQueues[0] = primarySampleQueue; + + for (int i = 0; i < embeddedTrackCount; i++) { + SampleQueue sampleQueue = + new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + embeddedSampleQueues[i] = sampleQueue; + sampleQueues[i + 1] = sampleQueue; + trackTypes[i + 1] = embeddedTrackTypes[i]; + } + + chunkOutput = new BaseMediaChunkOutput(trackTypes, sampleQueues); + pendingResetPositionUs = positionUs; + lastSeekPositionUs = positionUs; + } + + /** + * Discards buffered media up to the specified position. + * + * @param positionUs The position to discard up to, in microseconds. + * @param toKeyframe If true then for each track discards samples up to the keyframe before or at + * the specified position, rather than any sample before or at that position. + */ + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (isPendingReset()) { + return; + } + int oldFirstSampleIndex = primarySampleQueue.getFirstIndex(); + primarySampleQueue.discardTo(positionUs, toKeyframe, true); + int newFirstSampleIndex = primarySampleQueue.getFirstIndex(); + if (newFirstSampleIndex > oldFirstSampleIndex) { + long discardToUs = primarySampleQueue.getFirstTimestampUs(); + for (int i = 0; i < embeddedSampleQueues.length; i++) { + embeddedSampleQueues[i].discardTo(discardToUs, toKeyframe, embeddedTracksSelected[i]); + } + } + discardDownstreamMediaChunks(newFirstSampleIndex); + } + + /** + * Selects the embedded track, returning a new {@link EmbeddedSampleStream} from which the track's + * samples can be consumed. {@link EmbeddedSampleStream#release()} must be called on the returned + * stream when the track is no longer required, and before calling this method again to obtain + * another stream for the same track. + * + * @param positionUs The current playback position in microseconds. + * @param trackType The type of the embedded track to enable. + * @return The {@link EmbeddedSampleStream} for the embedded track. + */ + public EmbeddedSampleStream selectEmbeddedTrack(long positionUs, int trackType) { + for (int i = 0; i < embeddedSampleQueues.length; i++) { + if (embeddedTrackTypes[i] == trackType) { + Assertions.checkState(!embeddedTracksSelected[i]); + embeddedTracksSelected[i] = true; + embeddedSampleQueues[i].seekTo(positionUs, /* allowTimeBeyondBuffer= */ true); + return new EmbeddedSampleStream(this, embeddedSampleQueues[i], i); + } + } + // Should never happen. + throw new IllegalStateException(); + } + + /** + * Returns the {@link ChunkSource} used by this stream. + */ + public T getChunkSource() { + return chunkSource; + } + + /** + * Returns an estimate of the position up to which data is buffered. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the track is fully buffered. + */ + @Override + public long getBufferedPositionUs() { + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } else { + long bufferedPositionUs = lastSeekPositionUs; + BaseMediaChunk lastMediaChunk = getLastMediaChunk(); + BaseMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk + : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; + if (lastCompletedMediaChunk != null) { + bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + } + return Math.max(bufferedPositionUs, primarySampleQueue.getLargestQueuedTimestampUs()); + } + } + + /** + * Adjusts a seek position given the specified {@link SeekParameters}. Chunk boundaries are used + * as sync points. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. + * @return The adjusted seek position, in microseconds. + */ + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return chunkSource.getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + /** + * Seeks to the specified position in microseconds. + * + * @param positionUs The seek position in microseconds. + */ + public void seekToUs(long positionUs) { + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return; + } + + // Detect whether the seek is to the start of a chunk that's at least partially buffered. + BaseMediaChunk seekToMediaChunk = null; + for (int i = 0; i < mediaChunks.size(); i++) { + BaseMediaChunk mediaChunk = mediaChunks.get(i); + long mediaChunkStartTimeUs = mediaChunk.startTimeUs; + if (mediaChunkStartTimeUs == positionUs && mediaChunk.clippedStartTimeUs == C.TIME_UNSET) { + seekToMediaChunk = mediaChunk; + break; + } else if (mediaChunkStartTimeUs > positionUs) { + // We're not going to find a chunk with a matching start time. + break; + } + } + + // See if we can seek inside the primary sample queue. + boolean seekInsideBuffer; + if (seekToMediaChunk != null) { + // When seeking to the start of a chunk we use the index of the first sample in the chunk + // rather than the seek position. This ensures we seek to the keyframe at the start of the + // chunk even if the sample timestamps are slightly offset from the chunk start times. + seekInsideBuffer = primarySampleQueue.seekTo(seekToMediaChunk.getFirstSampleIndex(0)); + decodeOnlyUntilPositionUs = 0; + } else { + seekInsideBuffer = + primarySampleQueue.seekTo( + positionUs, /* allowTimeBeyondBuffer= */ positionUs < getNextLoadPositionUs()); + decodeOnlyUntilPositionUs = lastSeekPositionUs; + } + + if (seekInsideBuffer) { + // We can seek inside the buffer. + nextNotifyPrimaryFormatMediaChunkIndex = + primarySampleIndexToMediaChunkIndex( + primarySampleQueue.getReadIndex(), /* minChunkIndex= */ 0); + // Seek the embedded sample queues. + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true); + } + } else { + // We can't seek inside the buffer, and so need to reset. + pendingResetPositionUs = positionUs; + loadingFinished = false; + mediaChunks.clear(); + nextNotifyPrimaryFormatMediaChunkIndex = 0; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + primarySampleQueue.reset(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.reset(); + } + } + } + } + + /** + * Releases the stream. + * + * <p>This method should be called when the stream is no longer required. Either this method or + * {@link #release(ReleaseCallback)} can be used to release this stream. + */ + public void release() { + release(null); + } + + /** + * Releases the stream. + * + * <p>This method should be called when the stream is no longer required. Either this method or + * {@link #release()} can be used to release this stream. + * + * @param callback An optional callback to be called on the loading thread once the loader has + * been released. + */ + public void release(@Nullable ReleaseCallback<T> callback) { + this.releaseCallback = callback; + // Discard as much as we can synchronously. + primarySampleQueue.preRelease(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.preRelease(); + } + loader.release(this); + } + + @Override + public void onLoaderReleased() { + primarySampleQueue.release(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.release(); + } + if (releaseCallback != null) { + releaseCallback.onSampleStreamReleased(this); + } + } + + // SampleStream implementation. + + @Override + public boolean isReady() { + return !isPendingReset() && primarySampleQueue.isReady(loadingFinished); + } + + @Override + public void maybeThrowError() throws IOException { + loader.maybeThrowError(); + primarySampleQueue.maybeThrowError(); + if (!loader.isLoading()) { + chunkSource.maybeThrowError(); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyPrimaryTrackFormatChanged(); + + return primarySampleQueue.read( + formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilPositionUs); + } + + @Override + public int skipData(long positionUs) { + if (isPendingReset()) { + return 0; + } + int skipCount; + if (loadingFinished && positionUs > primarySampleQueue.getLargestQueuedTimestampUs()) { + skipCount = primarySampleQueue.advanceToEnd(); + } else { + skipCount = primarySampleQueue.advanceTo(positionUs); + } + maybeNotifyPrimaryTrackFormatChanged(); + return skipCount; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + chunkSource.onChunkLoadCompleted(loadable); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + callback.onContinueLoadingRequested(this); + } + + @Override + public void onLoadCanceled(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!released) { + primarySampleQueue.reset(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.reset(); + } + callback.onContinueLoadingRequested(this); + } + } + + @Override + public LoadErrorAction onLoadError( + Chunk loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long bytesLoaded = loadable.bytesLoaded(); + boolean isMediaChunk = isMediaChunk(loadable); + int lastChunkIndex = mediaChunks.size() - 1; + boolean cancelable = + bytesLoaded == 0 || !isMediaChunk || !haveReadFromMediaChunk(lastChunkIndex); + long blacklistDurationMs = + cancelable + ? loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount) + : C.TIME_UNSET; + LoadErrorAction loadErrorAction = null; + if (chunkSource.onChunkLoadError(loadable, cancelable, error, blacklistDurationMs)) { + if (cancelable) { + loadErrorAction = Loader.DONT_RETRY; + if (isMediaChunk) { + BaseMediaChunk removed = discardUpstreamMediaChunksFromIndex(lastChunkIndex); + Assertions.checkState(removed == loadable); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + } + } else { + Log.w(TAG, "Ignoring attempt to cancel non-cancelable load."); + } + } + + if (loadErrorAction == null) { + // The load was not cancelled. Either the load must be retried or the error propagated. + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelayMs != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) + : Loader.DONT_RETRY_FATAL; + } + + boolean canceled = !loadErrorAction.isRetry(); + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + canceled); + if (canceled) { + callback.onContinueLoadingRequested(this); + } + return loadErrorAction; + } + + // SequenceableLoader implementation + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + + boolean pendingReset = isPendingReset(); + List<BaseMediaChunk> chunkQueue; + long loadPositionUs; + if (pendingReset) { + chunkQueue = Collections.emptyList(); + loadPositionUs = pendingResetPositionUs; + } else { + chunkQueue = readOnlyMediaChunks; + loadPositionUs = getLastMediaChunk().endTimeUs; + } + chunkSource.getNextChunk(positionUs, loadPositionUs, chunkQueue, nextChunkHolder); + boolean endOfStream = nextChunkHolder.endOfStream; + Chunk loadable = nextChunkHolder.chunk; + nextChunkHolder.clear(); + + if (endOfStream) { + pendingResetPositionUs = C.TIME_UNSET; + loadingFinished = true; + return true; + } + + if (loadable == null) { + return false; + } + + if (isMediaChunk(loadable)) { + BaseMediaChunk mediaChunk = (BaseMediaChunk) loadable; + if (pendingReset) { + boolean resetToMediaChunk = mediaChunk.startTimeUs == pendingResetPositionUs; + // Only enable setting of the decode only flag if we're not resetting to a chunk boundary. + decodeOnlyUntilPositionUs = resetToMediaChunk ? 0 : pendingResetPositionUs; + pendingResetPositionUs = C.TIME_UNSET; + } + mediaChunk.init(chunkOutput); + mediaChunks.add(mediaChunk); + } else if (loadable instanceof InitializationChunk) { + ((InitializationChunk) loadable).init(chunkOutput); + } + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); + eventDispatcher.loadStarted( + loadable.dataSpec, + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + if (isPendingReset()) { + return pendingResetPositionUs; + } else { + return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs; + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + if (loader.isLoading() || loader.hasFatalError() || isPendingReset()) { + return; + } + + int currentQueueSize = mediaChunks.size(); + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (currentQueueSize <= preferredQueueSize) { + return; + } + + int newQueueSize = currentQueueSize; + for (int i = preferredQueueSize; i < currentQueueSize; i++) { + if (!haveReadFromMediaChunk(i)) { + newQueueSize = i; + break; + } + } + if (newQueueSize == currentQueueSize) { + return; + } + + long endTimeUs = getLastMediaChunk().endTimeUs; + BaseMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + loadingFinished = false; + eventDispatcher.upstreamDiscarded(primaryTrackType, firstRemovedChunk.startTimeUs, endTimeUs); + } + + // Internal methods + + private boolean isMediaChunk(Chunk chunk) { + return chunk instanceof BaseMediaChunk; + } + + /** Returns whether samples have been read from media chunk at given index. */ + private boolean haveReadFromMediaChunk(int mediaChunkIndex) { + BaseMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); + if (primarySampleQueue.getReadIndex() > mediaChunk.getFirstSampleIndex(0)) { + return true; + } + for (int i = 0; i < embeddedSampleQueues.length; i++) { + if (embeddedSampleQueues[i].getReadIndex() > mediaChunk.getFirstSampleIndex(i + 1)) { + return true; + } + } + return false; + } + + /* package */ boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + private void discardDownstreamMediaChunks(int discardToSampleIndex) { + int discardToMediaChunkIndex = + primarySampleIndexToMediaChunkIndex(discardToSampleIndex, /* minChunkIndex= */ 0); + // Don't discard any chunks that we haven't reported the primary format change for yet. + discardToMediaChunkIndex = + Math.min(discardToMediaChunkIndex, nextNotifyPrimaryFormatMediaChunkIndex); + if (discardToMediaChunkIndex > 0) { + Util.removeRange(mediaChunks, /* fromIndex= */ 0, /* toIndex= */ discardToMediaChunkIndex); + nextNotifyPrimaryFormatMediaChunkIndex -= discardToMediaChunkIndex; + } + } + + private void maybeNotifyPrimaryTrackFormatChanged() { + int readSampleIndex = primarySampleQueue.getReadIndex(); + int notifyToMediaChunkIndex = + primarySampleIndexToMediaChunkIndex( + readSampleIndex, /* minChunkIndex= */ nextNotifyPrimaryFormatMediaChunkIndex - 1); + while (nextNotifyPrimaryFormatMediaChunkIndex <= notifyToMediaChunkIndex) { + maybeNotifyPrimaryTrackFormatChanged(nextNotifyPrimaryFormatMediaChunkIndex++); + } + } + + private void maybeNotifyPrimaryTrackFormatChanged(int mediaChunkReadIndex) { + BaseMediaChunk currentChunk = mediaChunks.get(mediaChunkReadIndex); + Format trackFormat = currentChunk.trackFormat; + if (!trackFormat.equals(primaryDownstreamTrackFormat)) { + eventDispatcher.downstreamFormatChanged(primaryTrackType, trackFormat, + currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + currentChunk.startTimeUs); + } + primaryDownstreamTrackFormat = trackFormat; + } + + /** + * Returns the media chunk index corresponding to a given primary sample index. + * + * @param primarySampleIndex The primary sample index for which the corresponding media chunk + * index is required. + * @param minChunkIndex A minimum chunk index from which to start searching, or -1 if no hint can + * be provided. + * @return The index of the media chunk corresponding to the sample index, or -1 if the list of + * media chunks is empty, or {@code minChunkIndex} if the sample precedes the first chunk in + * the search (i.e. the chunk at {@code minChunkIndex}, or at index 0 if {@code minChunkIndex} + * is -1. + */ + private int primarySampleIndexToMediaChunkIndex(int primarySampleIndex, int minChunkIndex) { + for (int i = minChunkIndex + 1; i < mediaChunks.size(); i++) { + if (mediaChunks.get(i).getFirstSampleIndex(0) > primarySampleIndex) { + return i - 1; + } + } + return mediaChunks.size() - 1; + } + + private BaseMediaChunk getLastMediaChunk() { + return mediaChunks.get(mediaChunks.size() - 1); + } + + /** + * Discard upstream media chunks from {@code chunkIndex} and corresponding samples from sample + * queues. + * + * @param chunkIndex The index of the first chunk to discard. + * @return The chunk at given index. + */ + private BaseMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { + BaseMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); + Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); + nextNotifyPrimaryFormatMediaChunkIndex = + Math.max(nextNotifyPrimaryFormatMediaChunkIndex, mediaChunks.size()); + primarySampleQueue.discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(0)); + for (int i = 0; i < embeddedSampleQueues.length; i++) { + embeddedSampleQueues[i].discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(i + 1)); + } + return firstRemovedChunk; + } + + /** + * A {@link SampleStream} embedded in a {@link ChunkSampleStream}. + */ + public final class EmbeddedSampleStream implements SampleStream { + + public final ChunkSampleStream<T> parent; + + private final SampleQueue sampleQueue; + private final int index; + + private boolean notifiedDownstreamFormat; + + public EmbeddedSampleStream(ChunkSampleStream<T> parent, SampleQueue sampleQueue, int index) { + this.parent = parent; + this.sampleQueue = sampleQueue; + this.index = index; + } + + @Override + public boolean isReady() { + return !isPendingReset() && sampleQueue.isReady(loadingFinished); + } + + @Override + public int skipData(long positionUs) { + if (isPendingReset()) { + return 0; + } + maybeNotifyDownstreamFormat(); + int skipCount; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + skipCount = sampleQueue.advanceToEnd(); + } else { + skipCount = sampleQueue.advanceTo(positionUs); + } + return skipCount; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. Errors will be thrown from the primary stream. + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyDownstreamFormat(); + return sampleQueue.read( + formatHolder, + buffer, + formatRequired, + loadingFinished, + decodeOnlyUntilPositionUs); + } + + public void release() { + Assertions.checkState(embeddedTracksSelected[index]); + embeddedTracksSelected[index] = false; + } + + private void maybeNotifyDownstreamFormat() { + if (!notifiedDownstreamFormat) { + eventDispatcher.downstreamFormatChanged( + embeddedTrackTypes[index], + embeddedTrackFormats[index], + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + lastSeekPositionUs); + notifiedDownstreamFormat = true; + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java new file mode 100644 index 0000000000..33cee8e20e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import java.io.IOException; +import java.util.List; + +/** + * A provider of {@link Chunk}s for a {@link ChunkSampleStream} to load. + */ +public interface ChunkSource { + + /** + * Adjusts a seek position given the specified {@link SeekParameters}. Chunk boundaries are used + * as sync points. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. + * @return The adjusted seek position, in microseconds. + */ + long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters); + + /** + * If the source is currently having difficulty providing chunks, then this method throws the + * underlying error. Otherwise does nothing. + * <p> + * This method should only be called after the source has been prepared. + * + * @throws IOException The underlying error. + */ + void maybeThrowError() throws IOException; + + /** + * Evaluates whether {@link MediaChunk}s should be removed from the back of the queue. + * <p> + * Removing {@link MediaChunk}s from the back of the queue can be useful if they could be replaced + * with chunks of a significantly higher quality (e.g. because the available bandwidth has + * substantially increased). + * + * @param playbackPositionUs The current playback position. + * @param queue The queue of buffered {@link MediaChunk}s. + * @return The preferred queue size. + */ + int getPreferredQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue); + + /** + * Returns the next chunk to load. + * + * <p>If a chunk is available then {@link ChunkHolder#chunk} is set. If the end of the stream has + * been reached then {@link ChunkHolder#endOfStream} is set. If a chunk is not available but the + * end of the stream has not been reached, the {@link ChunkHolder} is not modified. + * + * @param playbackPositionUs The current playback position in microseconds. If playback of the + * period to which this chunk source belongs has not yet started, the value will be the + * starting position in the period minus the duration of any media in previous periods still + * to be played. + * @param loadPositionUs The current load position in microseconds. If {@code queue} is empty, + * this is the starting position from which chunks should be provided. Else it's equal to + * {@link MediaChunk#endTimeUs} of the last chunk in the {@code queue}. + * @param queue The queue of buffered {@link MediaChunk}s. + * @param out A holder to populate. + */ + void getNextChunk( + long playbackPositionUs, + long loadPositionUs, + List<? extends MediaChunk> queue, + ChunkHolder out); + + /** + * Called when the {@link ChunkSampleStream} has finished loading a chunk obtained from this + * source. + * + * <p>This method should only be called when the source is enabled. + * + * @param chunk The chunk whose load has been completed. + */ + void onChunkLoadCompleted(Chunk chunk); + + /** + * Called when the {@link ChunkSampleStream} encounters an error loading a chunk obtained from + * this source. + * + * <p>This method should only be called when the source is enabled. + * + * @param chunk The chunk whose load encountered the error. + * @param cancelable Whether the load can be canceled. + * @param e The error. + * @param blacklistDurationMs The duration for which the associated track may be blacklisted, or + * {@link C#TIME_UNSET} if the track may not be blacklisted. + * @return Whether the load should be canceled so that a replacement chunk can be loaded instead. + * Must be {@code false} if {@code cancelable} is {@code false}. If {@code true}, {@link + * #getNextChunk(long, long, List, ChunkHolder)} will be called to obtain the replacement + * chunk. + */ + boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java new file mode 100644 index 0000000000..98865e8b0e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link BaseMediaChunk} that uses an {@link Extractor} to decode sample data. + */ +public class ContainerMediaChunk extends BaseMediaChunk { + + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private final int chunkCount; + private final long sampleOffsetUs; + private final ChunkExtractorWrapper extractorWrapper; + + private long nextLoadPosition; + private volatile boolean loadCanceled; + private boolean loadCompleted; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param clippedStartTimeUs The time in the chunk from which output will begin, or {@link + * C#TIME_UNSET} to output from the start of the chunk. + * @param clippedEndTimeUs The time in the chunk from which output will end, or {@link + * C#TIME_UNSET} to output to the end of the chunk. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + * @param chunkCount The number of chunks in the underlying media that are spanned by this + * instance. Normally equal to one, but may be larger if multiple chunks as defined by the + * underlying media are being merged into a single load. + * @param sampleOffsetUs An offset to add to the sample timestamps parsed by the extractor. + * @param extractorWrapper A wrapped extractor to use for parsing the data. + */ + public ContainerMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long clippedStartTimeUs, + long clippedEndTimeUs, + long chunkIndex, + int chunkCount, + long sampleOffsetUs, + ChunkExtractorWrapper extractorWrapper) { + super( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + clippedStartTimeUs, + clippedEndTimeUs, + chunkIndex); + this.chunkCount = chunkCount; + this.sampleOffsetUs = sampleOffsetUs; + this.extractorWrapper = extractorWrapper; + } + + @Override + public long getNextChunkIndex() { + return chunkIndex + chunkCount; + } + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation. + + @Override + public final void cancelLoad() { + loadCanceled = true; + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public final void load() throws IOException, InterruptedException { + if (nextLoadPosition == 0) { + // Configure the output and set it as the target for the extractor wrapper. + BaseMediaChunkOutput output = getOutput(); + output.setSampleOffsetUs(sampleOffsetUs); + extractorWrapper.init( + getTrackOutputProvider(output), + clippedStartTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedStartTimeUs - sampleOffsetUs), + clippedEndTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedEndTimeUs - sampleOffsetUs)); + } + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + ExtractorInput input = + new DefaultExtractorInput( + dataSource, loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + // Load and decode the sample data. + try { + Extractor extractor = extractorWrapper.extractor; + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + Assertions.checkState(result != Extractor.RESULT_SEEK); + } finally { + nextLoadPosition = input.getPosition() - dataSpec.absoluteStreamPosition; + } + } finally { + Util.closeQuietly(dataSource); + } + loadCompleted = true; + } + + /** + * Returns the {@link TrackOutputProvider} to be used by the wrapped extractor. + * + * @param baseMediaChunkOutput The {@link BaseMediaChunkOutput} most recently passed to {@link + * #init(BaseMediaChunkOutput)}. + * @return A {@link TrackOutputProvider} to be used by the wrapped extractor. + */ + protected TrackOutputProvider getTrackOutputProvider(BaseMediaChunkOutput baseMediaChunkOutput) { + return baseMediaChunkOutput; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java new file mode 100644 index 0000000000..583f8ceeee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; + +/** + * A base class for {@link Chunk} implementations where the data should be loaded into a + * {@code byte[]} before being consumed. + */ +public abstract class DataChunk extends Chunk { + + private static final int READ_GRANULARITY = 16 * 1024; + + private byte[] data; + + private volatile boolean loadCanceled; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param type See {@link #type}. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param data An optional recycled array that can be used as a holder for the data. + */ + public DataChunk( + DataSource dataSource, + DataSpec dataSpec, + int type, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + byte[] data) { + super(dataSource, dataSpec, type, trackFormat, trackSelectionReason, trackSelectionData, + C.TIME_UNSET, C.TIME_UNSET); + this.data = data; + } + + /** + * Returns the array in which the data is held. + * <p> + * This method should be used for recycling the holder only, and not for reading the data. + * + * @return The array in which the data is held. + */ + public byte[] getDataHolder() { + return data; + } + + // Loadable implementation + + @Override + public final void cancelLoad() { + loadCanceled = true; + } + + @Override + public final void load() throws IOException, InterruptedException { + try { + dataSource.open(dataSpec); + int limit = 0; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT && !loadCanceled) { + maybeExpandData(limit); + bytesRead = dataSource.read(data, limit, READ_GRANULARITY); + if (bytesRead != -1) { + limit += bytesRead; + } + } + if (!loadCanceled) { + consume(data, limit); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + /** + * Called by {@link #load()}. Implementations should override this method to consume the loaded + * data. + * + * @param data An array containing the data. + * @param limit The limit of the data. + * @throws IOException If an error occurs consuming the loaded data. + */ + protected abstract void consume(byte[] data, int limit) throws IOException; + + private void maybeExpandData(int limit) { + if (data == null) { + data = new byte[READ_GRANULARITY]; + } else if (data.length < limit + READ_GRANULARITY) { + // The new length is calculated as (data.length + READ_GRANULARITY) rather than + // (limit + READ_GRANULARITY) in order to avoid small increments in the length. + data = Arrays.copyOf(data, data.length + READ_GRANULARITY); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java new file mode 100644 index 0000000000..db6e82c2c7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link Chunk} that uses an {@link Extractor} to decode initialization data for single track. + */ +public final class InitializationChunk extends Chunk { + + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private final ChunkExtractorWrapper extractorWrapper; + + @MonotonicNonNull private TrackOutputProvider trackOutputProvider; + private long nextLoadPosition; + private volatile boolean loadCanceled; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param extractorWrapper A wrapped extractor to use for parsing the initialization data. + */ + public InitializationChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + ChunkExtractorWrapper extractorWrapper) { + super(dataSource, dataSpec, C.DATA_TYPE_MEDIA_INITIALIZATION, trackFormat, trackSelectionReason, + trackSelectionData, C.TIME_UNSET, C.TIME_UNSET); + this.extractorWrapper = extractorWrapper; + } + + /** + * Initializes the chunk for loading, setting a {@link TrackOutputProvider} for track outputs to + * which formats will be written as they are loaded. + * + * @param trackOutputProvider The {@link TrackOutputProvider} for track outputs to which formats + * will be written as they are loaded. + */ + public void init(TrackOutputProvider trackOutputProvider) { + this.trackOutputProvider = trackOutputProvider; + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public void load() throws IOException, InterruptedException { + if (nextLoadPosition == 0) { + extractorWrapper.init( + trackOutputProvider, /* startTimeUs= */ C.TIME_UNSET, /* endTimeUs= */ C.TIME_UNSET); + } + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + ExtractorInput input = + new DefaultExtractorInput( + dataSource, loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + // Load and decode the initialization data. + try { + Extractor extractor = extractorWrapper.extractor; + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + Assertions.checkState(result != Extractor.RESULT_SEEK); + } finally { + nextLoadPosition = input.getPosition() - dataSpec.absoluteStreamPosition; + } + } finally { + Util.closeQuietly(dataSource); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java new file mode 100644 index 0000000000..81c9d216b9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * An abstract base class for {@link Chunk}s that contain media samples. + */ +public abstract class MediaChunk extends Chunk { + + /** The chunk index, or {@link C#INDEX_UNSET} if it is not known. */ + public final long chunkIndex; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + */ + public MediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkIndex) { + super(dataSource, dataSpec, C.DATA_TYPE_MEDIA, trackFormat, trackSelectionReason, + trackSelectionData, startTimeUs, endTimeUs); + Assertions.checkNotNull(trackFormat); + this.chunkIndex = chunkIndex; + } + + /** Returns the next chunk index or {@link C#INDEX_UNSET} if it is not known. */ + public long getNextChunkIndex() { + return chunkIndex != C.INDEX_UNSET ? chunkIndex + 1 : C.INDEX_UNSET; + } + + /** + * Returns whether the chunk has been fully loaded. + */ + public abstract boolean isLoadCompleted(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java new file mode 100644 index 0000000000..c6f5b1d41e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.util.NoSuchElementException; + +/** + * Iterator for media chunk sequences. + * + * <p>The iterator initially points in front of the first available element. The first call to + * {@link #next()} moves the iterator to the first element. Check the return value of {@link + * #next()} or {@link #isEnded()} to determine whether the iterator reached the end of the available + * data. + */ +public interface MediaChunkIterator { + + /** An empty media chunk iterator without available data. */ + MediaChunkIterator EMPTY = + new MediaChunkIterator() { + @Override + public boolean isEnded() { + return true; + } + + @Override + public boolean next() { + return false; + } + + @Override + public DataSpec getDataSpec() { + throw new NoSuchElementException(); + } + + @Override + public long getChunkStartTimeUs() { + throw new NoSuchElementException(); + } + + @Override + public long getChunkEndTimeUs() { + throw new NoSuchElementException(); + } + + @Override + public void reset() { + // Do nothing. + } + }; + + /** Returns whether the iteration has reached the end of the available data. */ + boolean isEnded(); + + /** + * Moves the iterator to the next media chunk. + * + * <p>Check the return value or {@link #isEnded()} to determine whether the iterator reached the + * end of the available data. + * + * @return Whether the iterator points to a media chunk with available data. + */ + boolean next(); + + /** + * Returns the {@link DataSpec} used to load the media chunk. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + DataSpec getDataSpec(); + + /** + * Returns the media start time of the chunk, in microseconds. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + long getChunkStartTimeUs(); + + /** + * Returns the media end time of the chunk, in microseconds. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + long getChunkEndTimeUs(); + + /** Resets the iterator to the initial position. */ + void reset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java new file mode 100644 index 0000000000..1b3004418e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.util.List; + +/** A {@link MediaChunkIterator} which iterates over a {@link List} of {@link MediaChunk}s. */ +public final class MediaChunkListIterator extends BaseMediaChunkIterator { + + private final List<? extends MediaChunk> chunks; + private final boolean reverseOrder; + + /** + * Creates iterator. + * + * @param chunks The list of chunks to iterate over. + * @param reverseOrder Whether to iterate in reverse order. + */ + public MediaChunkListIterator(List<? extends MediaChunk> chunks, boolean reverseOrder) { + super(0, chunks.size() - 1); + this.chunks = chunks; + this.reverseOrder = reverseOrder; + } + + @Override + public DataSpec getDataSpec() { + return getCurrentChunk().dataSpec; + } + + @Override + public long getChunkStartTimeUs() { + return getCurrentChunk().startTimeUs; + } + + @Override + public long getChunkEndTimeUs() { + return getCurrentChunk().endTimeUs; + } + + private MediaChunk getCurrentChunk() { + int index = (int) super.getCurrentIndex(); + if (reverseOrder) { + index = chunks.size() - 1 - index; + } + return chunks.get(index); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java new file mode 100644 index 0000000000..b3d30408ee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link BaseMediaChunk} for chunks consisting of a single raw sample. + */ +public final class SingleSampleMediaChunk extends BaseMediaChunk { + + private final int trackType; + private final Format sampleFormat; + + private long nextLoadPosition; + private boolean loadCompleted; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + * @param trackType The type of the chunk. Typically one of the {@link C} {@code TRACK_TYPE_*} + * constants. + * @param sampleFormat The {@link Format} of the sample in the chunk. + */ + public SingleSampleMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkIndex, + int trackType, + Format sampleFormat) { + super( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + /* clippedStartTimeUs= */ C.TIME_UNSET, + /* clippedEndTimeUs= */ C.TIME_UNSET, + chunkIndex); + this.trackType = trackType; + this.sampleFormat = sampleFormat; + } + + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + // Do nothing. + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public void load() throws IOException, InterruptedException { + BaseMediaChunkOutput output = getOutput(); + output.setSampleOffsetUs(0); + TrackOutput trackOutput = output.track(0, trackType); + trackOutput.format(sampleFormat); + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + long length = dataSource.open(loadDataSpec); + if (length != C.LENGTH_UNSET) { + length += nextLoadPosition; + } + ExtractorInput extractorInput = + new DefaultExtractorInput(dataSource, nextLoadPosition, length); + // Load the sample data. + int result = 0; + while (result != C.RESULT_END_OF_INPUT) { + nextLoadPosition += result; + result = trackOutput.sampleData(extractorInput, Integer.MAX_VALUE, true); + } + int sampleSize = (int) nextLoadPosition; + trackOutput.sampleMetadata(startTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + } finally { + Util.closeQuietly(dataSource); + } + loadCompleted = true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java new file mode 100644 index 0000000000..4643c0402c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceInputStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.AlgorithmParameterSpec; +import java.util.List; +import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A {@link DataSource} that decrypts data read from an upstream source, encrypted with AES-128 with + * a 128-bit key and PKCS7 padding. + * + * <p>Note that this {@link DataSource} does not support being opened from arbitrary offsets. It is + * designed specifically for reading whole files as defined in an HLS media playlist. For this + * reason the implementation is private to the HLS package. + */ +/* package */ class Aes128DataSource implements DataSource { + + private final DataSource upstream; + private final byte[] encryptionKey; + private final byte[] encryptionIv; + + @Nullable private CipherInputStream cipherInputStream; + + /** + * @param upstream The upstream {@link DataSource}. + * @param encryptionKey The encryption key. + * @param encryptionIv The encryption initialization vector. + */ + public Aes128DataSource(DataSource upstream, byte[] encryptionKey, byte[] encryptionIv) { + this.upstream = upstream; + this.encryptionKey = encryptionKey; + this.encryptionIv = encryptionIv; + } + + @Override + public final void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public final long open(DataSpec dataSpec) throws IOException { + Cipher cipher; + try { + cipher = getCipherInstance(); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new RuntimeException(e); + } + + Key cipherKey = new SecretKeySpec(encryptionKey, "AES"); + AlgorithmParameterSpec cipherIV = new IvParameterSpec(encryptionIv); + + try { + cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + + DataSourceInputStream inputStream = new DataSourceInputStream(upstream, dataSpec); + cipherInputStream = new CipherInputStream(inputStream, cipher); + inputStream.open(); + + return C.LENGTH_UNSET; + } + + @Override + public final int read(byte[] buffer, int offset, int readLength) throws IOException { + Assertions.checkNotNull(cipherInputStream); + int bytesRead = cipherInputStream.read(buffer, offset, readLength); + if (bytesRead < 0) { + return C.RESULT_END_OF_INPUT; + } + return bytesRead; + } + + @Override + @Nullable + public final Uri getUri() { + return upstream.getUri(); + } + + @Override + public final Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + if (cipherInputStream != null) { + cipherInputStream = null; + upstream.close(); + } + } + + protected Cipher getCipherInstance() throws NoSuchPaddingException, NoSuchAlgorithmException { + return Cipher.getInstance("AES/CBC/PKCS7Padding"); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java new file mode 100644 index 0000000000..cbe2f797b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; + +/** + * Default implementation of {@link HlsDataSourceFactory}. + */ +public final class DefaultHlsDataSourceFactory implements HlsDataSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + /** + * @param dataSourceFactory The {@link DataSource.Factory} to use for all data types. + */ + public DefaultHlsDataSourceFactory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + } + + @Override + public DataSource createDataSource(int dataType) { + return dataSourceFactory.createDataSource(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java new file mode 100644 index 0000000000..6f39e1bff8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -0,0 +1,338 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.EOFException; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Default {@link HlsExtractorFactory} implementation. + */ +public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { + + public static final String AAC_FILE_EXTENSION = ".aac"; + public static final String AC3_FILE_EXTENSION = ".ac3"; + public static final String EC3_FILE_EXTENSION = ".ec3"; + public static final String AC4_FILE_EXTENSION = ".ac4"; + public static final String MP3_FILE_EXTENSION = ".mp3"; + public static final String MP4_FILE_EXTENSION = ".mp4"; + public static final String M4_FILE_EXTENSION_PREFIX = ".m4"; + public static final String MP4_FILE_EXTENSION_PREFIX = ".mp4"; + public static final String CMF_FILE_EXTENSION_PREFIX = ".cmf"; + public static final String VTT_FILE_EXTENSION = ".vtt"; + public static final String WEBVTT_FILE_EXTENSION = ".webvtt"; + + @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags; + private final boolean exposeCea608WhenMissingDeclarations; + + /** + * Equivalent to {@link #DefaultHlsExtractorFactory(int, boolean) new + * DefaultHlsExtractorFactory(payloadReaderFactoryFlags = 0, exposeCea608WhenMissingDeclarations = + * true)} + */ + public DefaultHlsExtractorFactory() { + this(/* payloadReaderFactoryFlags= */ 0, /* exposeCea608WhenMissingDeclarations */ true); + } + + /** + * Creates a factory for HLS segment extractors. + * + * @param payloadReaderFactoryFlags Flags to add when constructing any {@link + * DefaultTsPayloadReaderFactory} instances. Other flags may be added on top of {@code + * payloadReaderFactoryFlags} when creating {@link DefaultTsPayloadReaderFactory}. + * @param exposeCea608WhenMissingDeclarations Whether created {@link TsExtractor} instances should + * expose a CEA-608 track should the master playlist contain no Closed Captions declarations. + * If the master playlist contains any Closed Captions declarations, this flag is ignored. + */ + public DefaultHlsExtractorFactory( + int payloadReaderFactoryFlags, boolean exposeCea608WhenMissingDeclarations) { + this.payloadReaderFactoryFlags = payloadReaderFactoryFlags; + this.exposeCea608WhenMissingDeclarations = exposeCea608WhenMissingDeclarations; + } + + @Override + public Result createExtractor( + @Nullable Extractor previousExtractor, + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster, + Map<String, List<String>> responseHeaders, + ExtractorInput extractorInput) + throws InterruptedException, IOException { + + if (previousExtractor != null) { + // A extractor has already been successfully used. Return one of the same type. + if (isReusable(previousExtractor)) { + return buildResult(previousExtractor); + } else { + Result result = + buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster); + if (result == null) { + throw new IllegalArgumentException( + "Unexpected previousExtractor type: " + previousExtractor.getClass().getSimpleName()); + } + } + } + + // Try selecting the extractor by the file extension. + Extractor extractorByFileExtension = + createExtractorByFileExtension(uri, format, muxedCaptionFormats, timestampAdjuster); + extractorInput.resetPeekPosition(); + if (sniffQuietly(extractorByFileExtension, extractorInput)) { + return buildResult(extractorByFileExtension); + } + + // We need to manually sniff each known type, without retrying the one selected by file + // extension. + + if (!(extractorByFileExtension instanceof WebvttExtractor)) { + WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); + if (sniffQuietly(webvttExtractor, extractorInput)) { + return buildResult(webvttExtractor); + } + } + + if (!(extractorByFileExtension instanceof AdtsExtractor)) { + AdtsExtractor adtsExtractor = new AdtsExtractor(); + if (sniffQuietly(adtsExtractor, extractorInput)) { + return buildResult(adtsExtractor); + } + } + + if (!(extractorByFileExtension instanceof Ac3Extractor)) { + Ac3Extractor ac3Extractor = new Ac3Extractor(); + if (sniffQuietly(ac3Extractor, extractorInput)) { + return buildResult(ac3Extractor); + } + } + + if (!(extractorByFileExtension instanceof Ac4Extractor)) { + Ac4Extractor ac4Extractor = new Ac4Extractor(); + if (sniffQuietly(ac4Extractor, extractorInput)) { + return buildResult(ac4Extractor); + } + } + + if (!(extractorByFileExtension instanceof Mp3Extractor)) { + Mp3Extractor mp3Extractor = + new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + if (sniffQuietly(mp3Extractor, extractorInput)) { + return buildResult(mp3Extractor); + } + } + + if (!(extractorByFileExtension instanceof FragmentedMp4Extractor)) { + FragmentedMp4Extractor fragmentedMp4Extractor = + createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) { + return buildResult(fragmentedMp4Extractor); + } + } + + if (!(extractorByFileExtension instanceof TsExtractor)) { + TsExtractor tsExtractor = + createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + if (sniffQuietly(tsExtractor, extractorInput)) { + return buildResult(tsExtractor); + } + } + + // Fall back on the extractor created by file extension. + return buildResult(extractorByFileExtension); + } + + private Extractor createExtractorByFileExtension( + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster) { + String lastPathSegment = uri.getLastPathSegment(); + if (lastPathSegment == null) { + lastPathSegment = ""; + } + if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType) + || lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) + || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { + return new WebvttExtractor(format.language, timestampAdjuster); + } else if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { + return new AdtsExtractor(); + } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { + return new Ac3Extractor(); + } else if (lastPathSegment.endsWith(AC4_FILE_EXTENSION)) { + return new Ac4Extractor(); + } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { + return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION) + || lastPathSegment.startsWith(M4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4) + || lastPathSegment.startsWith(MP4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5) + || lastPathSegment.startsWith(CMF_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)) { + return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + } else { + // For any other file extension, we assume TS format. + return createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + } + } + + private static TsExtractor createTsExtractor( + @DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags, + boolean exposeCea608WhenMissingDeclarations, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster) { + @DefaultTsPayloadReaderFactory.Flags + int payloadReaderFactoryFlags = + DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM + | userProvidedPayloadReaderFactoryFlags; + if (muxedCaptionFormats != null) { + // The playlist declares closed caption renditions, we should ignore descriptors. + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS; + } else if (exposeCea608WhenMissingDeclarations) { + // The playlist does not provide any closed caption information. We preemptively declare a + // closed caption track on channel 0. + muxedCaptionFormats = + Collections.singletonList( + Format.createTextSampleFormat( + /* id= */ null, + MimeTypes.APPLICATION_CEA608, + /* selectionFlags= */ 0, + /* language= */ null)); + } else { + muxedCaptionFormats = Collections.emptyList(); + } + String codecs = format.codecs; + if (!TextUtils.isEmpty(codecs)) { + // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really + // exist. If we know from the codec attribute that they don't exist, then we can + // explicitly ignore them even if they're declared. + if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM; + } + if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM; + } + } + + return new TsExtractor( + TsExtractor.MODE_HLS, + timestampAdjuster, + new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats)); + } + + private static FragmentedMp4Extractor createFragmentedMp4Extractor( + TimestampAdjuster timestampAdjuster, + Format format, + @Nullable List<Format> muxedCaptionFormats) { + // Only enable the EMSG TrackOutput if this is the 'variant' track (i.e. the main one) to avoid + // creating a separate EMSG track for every audio track in a video stream. + return new FragmentedMp4Extractor( + /* flags= */ isFmp4Variant(format) ? FragmentedMp4Extractor.FLAG_ENABLE_EMSG_TRACK : 0, + timestampAdjuster, + /* sideloadedTrack= */ null, + muxedCaptionFormats != null ? muxedCaptionFormats : Collections.emptyList()); + } + + /** Returns true if this {@code format} represents a 'variant' track (i.e. the main one). */ + private static boolean isFmp4Variant(Format format) { + Metadata metadata = format.metadata; + if (metadata == null) { + return false; + } + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof HlsTrackMetadataEntry) { + return !((HlsTrackMetadataEntry) entry).variantInfos.isEmpty(); + } + } + return false; + } + + @Nullable + private static Result buildResultForSameExtractorType( + Extractor previousExtractor, Format format, TimestampAdjuster timestampAdjuster) { + if (previousExtractor instanceof WebvttExtractor) { + return buildResult(new WebvttExtractor(format.language, timestampAdjuster)); + } else if (previousExtractor instanceof AdtsExtractor) { + return buildResult(new AdtsExtractor()); + } else if (previousExtractor instanceof Ac3Extractor) { + return buildResult(new Ac3Extractor()); + } else if (previousExtractor instanceof Ac4Extractor) { + return buildResult(new Ac4Extractor()); + } else if (previousExtractor instanceof Mp3Extractor) { + return buildResult(new Mp3Extractor()); + } else { + return null; + } + } + + private static Result buildResult(Extractor extractor) { + return new Result( + extractor, + extractor instanceof AdtsExtractor + || extractor instanceof Ac3Extractor + || extractor instanceof Ac4Extractor + || extractor instanceof Mp3Extractor, + isReusable(extractor)); + } + + private static boolean sniffQuietly(Extractor extractor, ExtractorInput input) + throws InterruptedException, IOException { + boolean result = false; + try { + result = extractor.sniff(input); + } catch (EOFException e) { + // Do nothing. + } finally { + input.resetPeekPosition(); + } + return result; + } + + private static boolean isReusable(Extractor previousExtractor) { + return previousExtractor instanceof TsExtractor + || previousExtractor instanceof FragmentedMp4Extractor; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java new file mode 100644 index 0000000000..eab538582d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * LRU cache that holds up to {@code maxSize} full-segment-encryption keys. Which each addition, + * once the cache's size exceeds {@code maxSize}, the oldest item (according to insertion order) is + * removed. + */ +/* package */ final class FullSegmentEncryptionKeyCache { + + private final LinkedHashMap<Uri, byte[]> backingMap; + + public FullSegmentEncryptionKeyCache(int maxSize) { + backingMap = + new LinkedHashMap<Uri, byte[]>( + /* initialCapacity= */ maxSize + 1, /* loadFactor= */ 1, /* accessOrder= */ false) { + @Override + protected boolean removeEldestEntry(Map.Entry<Uri, byte[]> eldest) { + return size() > maxSize; + } + }; + } + + /** + * Returns the {@code encryptionKey} cached against this {@code uri}, or null if {@code uri} is + * null or not present in the cache. + */ + @Nullable + public byte[] get(@Nullable Uri uri) { + if (uri == null) { + return null; + } + return backingMap.get(uri); + } + + /** + * Inserts an entry into the cache. + * + * @throws NullPointerException if {@code uri} or {@code encryptionKey} are null. + */ + @Nullable + public byte[] put(Uri uri, byte[] encryptionKey) { + return backingMap.put(Assertions.checkNotNull(uri), Assertions.checkNotNull(encryptionKey)); + } + + /** + * Returns true if {@code uri} is present in the cache. + * + * @throws NullPointerException if {@code uri} is null. + */ + public boolean containsUri(Uri uri) { + return backingMap.containsKey(Assertions.checkNotNull(uri)); + } + + /** + * Removes {@code uri} from the cache. If {@code uri} was present in the cahce, this returns the + * corresponding {@code encryptionKey}, otherwise null. + * + * @throws NullPointerException if {@code uri} is null. + */ + @Nullable + public byte[] remove(Uri uri) { + return backingMap.remove(Assertions.checkNotNull(uri)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java new file mode 100644 index 0000000000..da935389d8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -0,0 +1,668 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BehindLiveWindowException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.Chunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.DataChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.BaseTrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Source of Hls (possibly adaptive) chunks. */ +/* package */ class HlsChunkSource { + + /** + * Chunk holder that allows the scheduling of retries. + */ + public static final class HlsChunkHolder { + + public HlsChunkHolder() { + clear(); + } + + /** The chunk to be loaded next. */ + @Nullable public Chunk chunk; + + /** + * Indicates that the end of the stream has been reached. + */ + public boolean endOfStream; + + /** Indicates that the chunk source is waiting for the referred playlist to be refreshed. */ + @Nullable public Uri playlistUrl; + + /** + * Clears the holder. + */ + public void clear() { + chunk = null; + endOfStream = false; + playlistUrl = null; + } + + } + + /** + * The maximum number of keys that the key cache can hold. This value must be 2 or greater in + * order to hold initialization segment and media segment keys simultaneously. + */ + private static final int KEY_CACHE_SIZE = 4; + + private final HlsExtractorFactory extractorFactory; + private final DataSource mediaDataSource; + private final DataSource encryptionDataSource; + private final TimestampAdjusterProvider timestampAdjusterProvider; + private final Uri[] playlistUrls; + private final Format[] playlistFormats; + private final HlsPlaylistTracker playlistTracker; + private final TrackGroup trackGroup; + @Nullable private final List<Format> muxedCaptionFormats; + private final FullSegmentEncryptionKeyCache keyCache; + + private boolean isTimestampMaster; + private byte[] scratchSpace; + @Nullable private IOException fatalError; + @Nullable private Uri expectedPlaylistUrl; + private boolean independentSegments; + + // Note: The track group in the selection is typically *not* equal to trackGroup. This is due to + // the way in which HlsSampleStreamWrapper generates track groups. Use only index based methods + // in TrackSelection to avoid unexpected behavior. + private TrackSelection trackSelection; + private long liveEdgeInPeriodTimeUs; + private boolean seenExpectedPlaylistError; + + /** + * @param extractorFactory An {@link HlsExtractorFactory} from which to obtain the extractors for + * media chunks. + * @param playlistTracker The {@link HlsPlaylistTracker} from which to obtain media playlists. + * @param playlistUrls The {@link Uri}s of the media playlists that can be adapted between by this + * chunk source. + * @param playlistFormats The {@link Format Formats} corresponding to the media playlists. + * @param dataSourceFactory An {@link HlsDataSourceFactory} to create {@link DataSource}s for the + * chunks. + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. + * @param timestampAdjusterProvider A provider of {@link TimestampAdjuster} instances. If multiple + * {@link HlsChunkSource}s are used for a single playback, they should all share the same + * provider. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + */ + public HlsChunkSource( + HlsExtractorFactory extractorFactory, + HlsPlaylistTracker playlistTracker, + Uri[] playlistUrls, + Format[] playlistFormats, + HlsDataSourceFactory dataSourceFactory, + @Nullable TransferListener mediaTransferListener, + TimestampAdjusterProvider timestampAdjusterProvider, + @Nullable List<Format> muxedCaptionFormats) { + this.extractorFactory = extractorFactory; + this.playlistTracker = playlistTracker; + this.playlistUrls = playlistUrls; + this.playlistFormats = playlistFormats; + this.timestampAdjusterProvider = timestampAdjusterProvider; + this.muxedCaptionFormats = muxedCaptionFormats; + keyCache = new FullSegmentEncryptionKeyCache(KEY_CACHE_SIZE); + scratchSpace = Util.EMPTY_BYTE_ARRAY; + liveEdgeInPeriodTimeUs = C.TIME_UNSET; + mediaDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MEDIA); + if (mediaTransferListener != null) { + mediaDataSource.addTransferListener(mediaTransferListener); + } + encryptionDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_DRM); + trackGroup = new TrackGroup(playlistFormats); + int[] initialTrackSelection = new int[playlistUrls.length]; + for (int i = 0; i < playlistUrls.length; i++) { + initialTrackSelection[i] = i; + } + trackSelection = new InitializationTrackSelection(trackGroup, initialTrackSelection); + } + + /** + * If the source is currently having difficulty providing chunks, then this method throws the + * underlying error. Otherwise does nothing. + * + * @throws IOException The underlying error. + */ + public void maybeThrowError() throws IOException { + if (fatalError != null) { + throw fatalError; + } + if (expectedPlaylistUrl != null && seenExpectedPlaylistError) { + playlistTracker.maybeThrowPlaylistRefreshError(expectedPlaylistUrl); + } + } + + /** + * Returns the track group exposed by the source. + */ + public TrackGroup getTrackGroup() { + return trackGroup; + } + + /** + * Sets the current track selection. + * + * @param trackSelection The {@link TrackSelection}. + */ + public void setTrackSelection(TrackSelection trackSelection) { + this.trackSelection = trackSelection; + } + + /** Returns the current {@link TrackSelection}. */ + public TrackSelection getTrackSelection() { + return trackSelection; + } + + /** + * Resets the source. + */ + public void reset() { + fatalError = null; + } + + /** + * Sets whether this chunk source is responsible for initializing timestamp adjusters. + * + * @param isTimestampMaster True if this chunk source is responsible for initializing timestamp + * adjusters. + */ + public void setIsTimestampMaster(boolean isTimestampMaster) { + this.isTimestampMaster = isTimestampMaster; + } + + /** + * Returns the next chunk to load. + * + * <p>If a chunk is available then {@link HlsChunkHolder#chunk} is set. If the end of the stream + * has been reached then {@link HlsChunkHolder#endOfStream} is set. If a chunk is not available + * but the end of the stream has not been reached, {@link HlsChunkHolder#playlistUrl} is set to + * contain the {@link Uri} that refers to the playlist that needs refreshing. + * + * @param playbackPositionUs The current playback position relative to the period start in + * microseconds. If playback of the period to which this chunk source belongs has not yet + * started, the value will be the starting position in the period minus the duration of any + * media in previous periods still to be played. + * @param loadPositionUs The current load position relative to the period start in microseconds. + * @param queue The queue of buffered {@link HlsMediaChunk}s. + * @param allowEndOfStream Whether {@link HlsChunkHolder#endOfStream} is allowed to be set for + * non-empty media playlists. If {@code false}, the last available chunk is returned instead. + * If the media playlist is empty, {@link HlsChunkHolder#endOfStream} is always set. + * @param out A holder to populate. + */ + public void getNextChunk( + long playbackPositionUs, + long loadPositionUs, + List<HlsMediaChunk> queue, + boolean allowEndOfStream, + HlsChunkHolder out) { + HlsMediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1); + int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); + long bufferedDurationUs = loadPositionUs - playbackPositionUs; + long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(playbackPositionUs); + if (previous != null && !independentSegments) { + // Unless segments are known to be independent, switching tracks requires downloading + // overlapping segments. Hence we subtract the previous segment's duration from the buffered + // duration. + // This may affect the live-streaming adaptive track selection logic, when we compare the + // buffered duration to time-to-live-edge to decide whether to switch. Therefore, we subtract + // the duration of the last loaded segment from timeToLiveEdgeUs as well. + long subtractedDurationUs = previous.getDurationUs(); + bufferedDurationUs = Math.max(0, bufferedDurationUs - subtractedDurationUs); + if (timeToLiveEdgeUs != C.TIME_UNSET) { + timeToLiveEdgeUs = Math.max(0, timeToLiveEdgeUs - subtractedDurationUs); + } + } + + // Select the track. + MediaChunkIterator[] mediaChunkIterators = createMediaChunkIterators(previous, loadPositionUs); + trackSelection.updateSelectedTrack( + playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, mediaChunkIterators); + int selectedTrackIndex = trackSelection.getSelectedIndexInTrackGroup(); + + boolean switchingTrack = oldTrackIndex != selectedTrackIndex; + Uri selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; + if (!playlistTracker.isSnapshotValid(selectedPlaylistUrl)) { + out.playlistUrl = selectedPlaylistUrl; + seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl); + expectedPlaylistUrl = selectedPlaylistUrl; + // Retry when playlist is refreshed. + return; + } + HlsMediaPlaylist mediaPlaylist = + playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); + // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be non-null. + Assertions.checkNotNull(mediaPlaylist); + independentSegments = mediaPlaylist.hasIndependentSegments; + + updateLiveEdgeTimeUs(mediaPlaylist); + + // Select the chunk. + long startOfPlaylistInPeriodUs = + mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + long chunkMediaSequence = + getChunkMediaSequence( + previous, switchingTrack, mediaPlaylist, startOfPlaylistInPeriodUs, loadPositionUs); + if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null && switchingTrack) { + // We try getting the next chunk without adapting in case that's the reason for falling + // behind the live window. + selectedTrackIndex = oldTrackIndex; + selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; + mediaPlaylist = + playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); + // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be + // non-null. + Assertions.checkNotNull(mediaPlaylist); + startOfPlaylistInPeriodUs = + mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + chunkMediaSequence = previous.getNextChunkIndex(); + } + + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + fatalError = new BehindLiveWindowException(); + return; + } + + int segmentIndexInPlaylist = (int) (chunkMediaSequence - mediaPlaylist.mediaSequence); + int availableSegmentCount = mediaPlaylist.segments.size(); + if (segmentIndexInPlaylist >= availableSegmentCount) { + if (mediaPlaylist.hasEndTag) { + if (allowEndOfStream || availableSegmentCount == 0) { + out.endOfStream = true; + return; + } + segmentIndexInPlaylist = availableSegmentCount - 1; + } else /* Live */ { + out.playlistUrl = selectedPlaylistUrl; + seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl); + expectedPlaylistUrl = selectedPlaylistUrl; + return; + } + } + // We have a valid playlist snapshot, we can discard any playlist errors at this point. + seenExpectedPlaylistError = false; + expectedPlaylistUrl = null; + + // Handle encryption. + HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + + // Check if the segment or its initialization segment are fully encrypted. + Uri initSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment.initializationSegment); + out.chunk = maybeCreateEncryptionChunkFor(initSegmentKeyUri, selectedTrackIndex); + if (out.chunk != null) { + return; + } + Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment); + out.chunk = maybeCreateEncryptionChunkFor(mediaSegmentKeyUri, selectedTrackIndex); + if (out.chunk != null) { + return; + } + + out.chunk = + HlsMediaChunk.createInstance( + extractorFactory, + mediaDataSource, + playlistFormats[selectedTrackIndex], + startOfPlaylistInPeriodUs, + mediaPlaylist, + segmentIndexInPlaylist, + selectedPlaylistUrl, + muxedCaptionFormats, + trackSelection.getSelectionReason(), + trackSelection.getSelectionData(), + isTimestampMaster, + timestampAdjusterProvider, + previous, + /* mediaSegmentKey= */ keyCache.get(mediaSegmentKeyUri), + /* initSegmentKey= */ keyCache.get(initSegmentKeyUri)); + } + + /** + * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this + * source. + * + * @param chunk The chunk whose load has been completed. + */ + public void onChunkLoadCompleted(Chunk chunk) { + if (chunk instanceof EncryptionKeyChunk) { + EncryptionKeyChunk encryptionKeyChunk = (EncryptionKeyChunk) chunk; + scratchSpace = encryptionKeyChunk.getDataHolder(); + keyCache.put( + encryptionKeyChunk.dataSpec.uri, Assertions.checkNotNull(encryptionKeyChunk.getResult())); + } + } + + /** + * Attempts to blacklist the track associated with the given chunk. Blacklisting will fail if the + * track is the only non-blacklisted track in the selection. + * + * @param chunk The chunk whose load caused the blacklisting attempt. + * @param blacklistDurationMs The number of milliseconds for which the track selection should be + * blacklisted. + * @return Whether the blacklisting succeeded. + */ + public boolean maybeBlacklistTrack(Chunk chunk, long blacklistDurationMs) { + return trackSelection.blacklist( + trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), blacklistDurationMs); + } + + /** + * Called when a playlist load encounters an error. + * + * @param playlistUrl The {@link Uri} of the playlist whose load encountered an error. + * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or {@link + * C#TIME_UNSET} if the playlist should not be blacklisted. + * @return True if blacklisting did not encounter errors. False otherwise. + */ + public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + int trackGroupIndex = C.INDEX_UNSET; + for (int i = 0; i < playlistUrls.length; i++) { + if (playlistUrls[i].equals(playlistUrl)) { + trackGroupIndex = i; + break; + } + } + if (trackGroupIndex == C.INDEX_UNSET) { + return true; + } + int trackSelectionIndex = trackSelection.indexOf(trackGroupIndex); + if (trackSelectionIndex == C.INDEX_UNSET) { + return true; + } + seenExpectedPlaylistError |= playlistUrl.equals(expectedPlaylistUrl); + return blacklistDurationMs == C.TIME_UNSET + || trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs); + } + + /** + * Returns an array of {@link MediaChunkIterator}s for upcoming media chunks. + * + * @param previous The previous media chunk. May be null. + * @param loadPositionUs The position at which the iterators will start. + * @return Array of {@link MediaChunkIterator}s for each track. + */ + public MediaChunkIterator[] createMediaChunkIterators( + @Nullable HlsMediaChunk previous, long loadPositionUs) { + int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); + MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()]; + for (int i = 0; i < chunkIterators.length; i++) { + int trackIndex = trackSelection.getIndexInTrackGroup(i); + Uri playlistUrl = playlistUrls[trackIndex]; + if (!playlistTracker.isSnapshotValid(playlistUrl)) { + chunkIterators[i] = MediaChunkIterator.EMPTY; + continue; + } + HlsMediaPlaylist playlist = + playlistTracker.getPlaylistSnapshot(playlistUrl, /* isForPlayback= */ false); + // Playlist snapshot is valid (checked by if() above) so playlist must be non-null. + Assertions.checkNotNull(playlist); + long startOfPlaylistInPeriodUs = + playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + boolean switchingTrack = trackIndex != oldTrackIndex; + long chunkMediaSequence = + getChunkMediaSequence( + previous, switchingTrack, playlist, startOfPlaylistInPeriodUs, loadPositionUs); + if (chunkMediaSequence < playlist.mediaSequence) { + chunkIterators[i] = MediaChunkIterator.EMPTY; + continue; + } + int chunkIndex = (int) (chunkMediaSequence - playlist.mediaSequence); + chunkIterators[i] = + new HlsMediaPlaylistSegmentIterator(playlist, startOfPlaylistInPeriodUs, chunkIndex); + } + return chunkIterators; + } + + // Private methods. + + /** + * Returns the media sequence number of the segment to load next in {@code mediaPlaylist}. + * + * @param previous The last (at least partially) loaded segment. + * @param switchingTrack Whether the segment to load is not preceded by a segment in the same + * track. + * @param mediaPlaylist The media playlist to which the segment to load belongs. + * @param startOfPlaylistInPeriodUs The start of {@code mediaPlaylist} relative to the period + * start in microseconds. + * @param loadPositionUs The current load position relative to the period start in microseconds. + * @return The media sequence of the segment to load. + */ + private long getChunkMediaSequence( + @Nullable HlsMediaChunk previous, + boolean switchingTrack, + HlsMediaPlaylist mediaPlaylist, + long startOfPlaylistInPeriodUs, + long loadPositionUs) { + if (previous == null || switchingTrack) { + long endOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs + mediaPlaylist.durationUs; + long targetPositionInPeriodUs = + (previous == null || independentSegments) ? loadPositionUs : previous.startTimeUs; + if (!mediaPlaylist.hasEndTag && targetPositionInPeriodUs >= endOfPlaylistInPeriodUs) { + // If the playlist is too old to contain the chunk, we need to refresh it. + return mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(); + } + long targetPositionInPlaylistUs = targetPositionInPeriodUs - startOfPlaylistInPeriodUs; + return Util.binarySearchFloor( + mediaPlaylist.segments, + /* value= */ targetPositionInPlaylistUs, + /* inclusive= */ true, + /* stayInBounds= */ !playlistTracker.isLive() || previous == null) + + mediaPlaylist.mediaSequence; + } + // We ignore the case of previous not having loaded completely, in which case we load the next + // segment. + return previous.getNextChunkIndex(); + } + + private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { + final boolean resolveTimeToLiveEdgePossible = liveEdgeInPeriodTimeUs != C.TIME_UNSET; + return resolveTimeToLiveEdgePossible + ? liveEdgeInPeriodTimeUs - playbackPositionUs + : C.TIME_UNSET; + } + + private void updateLiveEdgeTimeUs(HlsMediaPlaylist mediaPlaylist) { + liveEdgeInPeriodTimeUs = + mediaPlaylist.hasEndTag + ? C.TIME_UNSET + : (mediaPlaylist.getEndTimeUs() - playlistTracker.getInitialStartTimeUs()); + } + + @Nullable + private Chunk maybeCreateEncryptionChunkFor(@Nullable Uri keyUri, int selectedTrackIndex) { + if (keyUri == null) { + return null; + } + + byte[] encryptionKey = keyCache.remove(keyUri); + if (encryptionKey != null) { + // The key was present in the key cache. We re-insert it to prevent it from being evicted by + // the following key addition. Note that removal of the key is necessary to affect the + // eviction order. + keyCache.put(keyUri, encryptionKey); + return null; + } + DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNSET, null, DataSpec.FLAG_ALLOW_GZIP); + return new EncryptionKeyChunk( + encryptionDataSource, + dataSpec, + playlistFormats[selectedTrackIndex], + trackSelection.getSelectionReason(), + trackSelection.getSelectionData(), + scratchSpace); + } + + @Nullable + private static Uri getFullEncryptionKeyUri(HlsMediaPlaylist playlist, @Nullable Segment segment) { + if (segment == null || segment.fullSegmentEncryptionKeyUri == null) { + return null; + } + return UriUtil.resolveToUri(playlist.baseUri, segment.fullSegmentEncryptionKeyUri); + } + + // Private classes. + + /** + * A {@link TrackSelection} to use for initialization. + */ + private static final class InitializationTrackSelection extends BaseTrackSelection { + + private int selectedIndex; + + public InitializationTrackSelection(TrackGroup group, int[] tracks) { + super(group, tracks); + selectedIndex = indexOf(group.getFormat(0)); + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + long nowMs = SystemClock.elapsedRealtime(); + if (!isBlacklisted(selectedIndex, nowMs)) { + return; + } + // Try from lowest bitrate to highest. + for (int i = length - 1; i >= 0; i--) { + if (!isBlacklisted(i, nowMs)) { + selectedIndex = i; + return; + } + } + // Should never happen. + throw new IllegalStateException(); + } + + @Override + public int getSelectedIndex() { + return selectedIndex; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_UNKNOWN; + } + + @Override + @Nullable + public Object getSelectionData() { + return null; + } + + } + + private static final class EncryptionKeyChunk extends DataChunk { + + private byte @MonotonicNonNull [] result; + + public EncryptionKeyChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + byte[] scratchSpace) { + super(dataSource, dataSpec, C.DATA_TYPE_DRM, trackFormat, trackSelectionReason, + trackSelectionData, scratchSpace); + } + + @Override + protected void consume(byte[] data, int limit) { + result = Arrays.copyOf(data, limit); + } + + /** Return the result of this chunk, or null if loading is not complete. */ + @Nullable + public byte[] getResult() { + return result; + } + + } + + /** {@link MediaChunkIterator} wrapping a {@link HlsMediaPlaylist}. */ + private static final class HlsMediaPlaylistSegmentIterator extends BaseMediaChunkIterator { + + private final HlsMediaPlaylist playlist; + private final long startOfPlaylistInPeriodUs; + + /** + * Creates iterator. + * + * @param playlist The {@link HlsMediaPlaylist} to wrap. + * @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in + * microseconds. + * @param chunkIndex The index of the first available chunk in the playlist. + */ + public HlsMediaPlaylistSegmentIterator( + HlsMediaPlaylist playlist, long startOfPlaylistInPeriodUs, int chunkIndex) { + super(/* fromIndex= */ chunkIndex, /* toIndex= */ playlist.segments.size() - 1); + this.playlist = playlist; + this.startOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs; + } + + @Override + public DataSpec getDataSpec() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + Uri chunkUri = UriUtil.resolveToUri(playlist.baseUri, segment.url); + return new DataSpec( + chunkUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null); + } + + @Override + public long getChunkStartTimeUs() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + return startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; + } + + @Override + public long getChunkEndTimeUs() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; + return segmentStartTimeInPeriodUs + segment.durationUs; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java new file mode 100644 index 0000000000..66fac54b8d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; + +/** + * Creates {@link DataSource}s for HLS playlists, encryption and media chunks. + */ +public interface HlsDataSourceFactory { + + /** + * Creates a {@link DataSource} for the given data type. + * + * @param dataType The data type for which the {@link DataSource} will be used. One of {@link C} + * {@code .DATA_TYPE_*} constants. + * @return A {@link DataSource} for the given data type. + */ + DataSource createDataSource(int dataType); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java new file mode 100644 index 0000000000..8f445f97ed --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Factory for HLS media chunk extractors. + */ +public interface HlsExtractorFactory { + + /** Holds an {@link Extractor} and associated parameters. */ + final class Result { + + /** The created extractor; */ + public final Extractor extractor; + /** Whether the segments for which {@link #extractor} is created are packed audio segments. */ + public final boolean isPackedAudioExtractor; + /** + * Whether {@link #extractor} may be reused for following continuous (no immediately preceding + * discontinuities) segments of the same variant. + */ + public final boolean isReusable; + + /** + * Creates a result. + * + * @param extractor See {@link #extractor}. + * @param isPackedAudioExtractor See {@link #isPackedAudioExtractor}. + * @param isReusable See {@link #isReusable}. + */ + public Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReusable) { + this.extractor = extractor; + this.isPackedAudioExtractor = isPackedAudioExtractor; + this.isReusable = isReusable; + } + } + + HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory(); + + /** + * Creates an {@link Extractor} for extracting HLS media chunks. + * + * @param previousExtractor A previously used {@link Extractor} which can be reused if the current + * chunk is a continuation of the previously extracted chunk, or null otherwise. It is the + * responsibility of implementers to only reuse extractors that are suited for reusage. + * @param uri The URI of the media chunk. + * @param format A {@link Format} associated with the chunk to extract. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + * @param timestampAdjuster Adjuster corresponding to the provided discontinuity sequence number. + * @param responseHeaders The HTTP response headers associated with the media segment or + * initialization section to extract. + * @param sniffingExtractorInput The first extractor input that will be passed to the returned + * extractor's {@link Extractor#read(ExtractorInput, PositionHolder)}. Must only be used to + * call {@link Extractor#sniff(ExtractorInput)}. + * @return A {@link Result}. + * @throws InterruptedException If the thread is interrupted while sniffing. + * @throws IOException If an I/O error is encountered while sniffing. + */ + Result createExtractor( + @Nullable Extractor previousExtractor, + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster, + Map<String, List<String>> responseHeaders, + ExtractorInput sniffingExtractorInput) + throws InterruptedException, IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java new file mode 100644 index 0000000000..52a5632134 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; + +/** + * Holds a master playlist along with a snapshot of one of its media playlists. + */ +public final class HlsManifest { + + /** + * The master playlist of an HLS stream. + */ + public final HlsMasterPlaylist masterPlaylist; + /** + * A snapshot of a media playlist referred to by {@link #masterPlaylist}. + */ + public final HlsMediaPlaylist mediaPlaylist; + + /** + * @param masterPlaylist The master playlist. + * @param mediaPlaylist The media playlist. + */ + HlsManifest(HlsMasterPlaylist masterPlaylist, HlsMediaPlaylist mediaPlaylist) { + this.masterPlaylist = masterPlaylist; + this.mediaPlaylist = mediaPlaylist; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java new file mode 100644 index 0000000000..173e53faad --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.math.BigInteger; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An HLS {@link MediaChunk}. + */ +/* package */ final class HlsMediaChunk extends MediaChunk { + + /** + * Creates a new instance. + * + * @param extractorFactory A {@link HlsExtractorFactory} from which the HLS media chunk extractor + * is obtained. + * @param dataSource The source from which the data should be loaded. + * @param format The chunk format. + * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds. + * @param mediaPlaylist The media playlist from which this chunk was obtained. + * @param playlistUrl The url of the playlist from which this chunk was obtained. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param isMasterTimestampSource True if the chunk can initialize the timestamp adjuster. + * @param timestampAdjusterProvider The provider from which to obtain the {@link + * TimestampAdjuster}. + * @param previousChunk The {@link HlsMediaChunk} that preceded this one. May be null. + * @param mediaSegmentKey The media segment decryption key, if fully encrypted. Null otherwise. + * @param initSegmentKey The initialization segment decryption key, if fully encrypted. Null + * otherwise. + */ + public static HlsMediaChunk createInstance( + HlsExtractorFactory extractorFactory, + DataSource dataSource, + Format format, + long startOfPlaylistInPeriodUs, + HlsMediaPlaylist mediaPlaylist, + int segmentIndexInPlaylist, + Uri playlistUrl, + @Nullable List<Format> muxedCaptionFormats, + int trackSelectionReason, + @Nullable Object trackSelectionData, + boolean isMasterTimestampSource, + TimestampAdjusterProvider timestampAdjusterProvider, + @Nullable HlsMediaChunk previousChunk, + @Nullable byte[] mediaSegmentKey, + @Nullable byte[] initSegmentKey) { + // Media segment. + HlsMediaPlaylist.Segment mediaSegment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + DataSpec dataSpec = + new DataSpec( + UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url), + mediaSegment.byterangeOffset, + mediaSegment.byterangeLength, + /* key= */ null); + boolean mediaSegmentEncrypted = mediaSegmentKey != null; + byte[] mediaSegmentIv = + mediaSegmentEncrypted + ? getEncryptionIvArray(Assertions.checkNotNull(mediaSegment.encryptionIV)) + : null; + DataSource mediaDataSource = buildDataSource(dataSource, mediaSegmentKey, mediaSegmentIv); + + // Init segment. + HlsMediaPlaylist.Segment initSegment = mediaSegment.initializationSegment; + DataSpec initDataSpec = null; + boolean initSegmentEncrypted = false; + DataSource initDataSource = null; + if (initSegment != null) { + initSegmentEncrypted = initSegmentKey != null; + byte[] initSegmentIv = + initSegmentEncrypted + ? getEncryptionIvArray(Assertions.checkNotNull(initSegment.encryptionIV)) + : null; + Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url); + initDataSpec = + new DataSpec( + initSegmentUri, + initSegment.byterangeOffset, + initSegment.byterangeLength, + /* key= */ null); + initDataSource = buildDataSource(dataSource, initSegmentKey, initSegmentIv); + } + + long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + mediaSegment.relativeStartTimeUs; + long segmentEndTimeInPeriodUs = segmentStartTimeInPeriodUs + mediaSegment.durationUs; + int discontinuitySequenceNumber = + mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence; + + Extractor previousExtractor = null; + Id3Decoder id3Decoder; + ParsableByteArray scratchId3Data; + boolean shouldSpliceIn; + if (previousChunk != null) { + id3Decoder = previousChunk.id3Decoder; + scratchId3Data = previousChunk.scratchId3Data; + shouldSpliceIn = + !playlistUrl.equals(previousChunk.playlistUrl) || !previousChunk.loadCompleted; + previousExtractor = + previousChunk.isExtractorReusable + && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber + && !shouldSpliceIn + ? previousChunk.extractor + : null; + } else { + id3Decoder = new Id3Decoder(); + scratchId3Data = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + shouldSpliceIn = false; + } + + return new HlsMediaChunk( + extractorFactory, + mediaDataSource, + dataSpec, + format, + mediaSegmentEncrypted, + initDataSource, + initDataSpec, + initSegmentEncrypted, + playlistUrl, + muxedCaptionFormats, + trackSelectionReason, + trackSelectionData, + segmentStartTimeInPeriodUs, + segmentEndTimeInPeriodUs, + /* chunkMediaSequence= */ mediaPlaylist.mediaSequence + segmentIndexInPlaylist, + discontinuitySequenceNumber, + mediaSegment.hasGapTag, + isMasterTimestampSource, + /* timestampAdjuster= */ timestampAdjusterProvider.getAdjuster(discontinuitySequenceNumber), + mediaSegment.drmInitData, + previousExtractor, + id3Decoder, + scratchId3Data, + shouldSpliceIn); + } + + public static final String PRIV_TIMESTAMP_FRAME_OWNER = + "com.apple.streaming.transportStreamTimestamp"; + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private static final AtomicInteger uidSource = new AtomicInteger(); + + /** + * A unique identifier for the chunk. + */ + public final int uid; + + /** + * The discontinuity sequence number of the chunk. + */ + public final int discontinuitySequenceNumber; + + /** The url of the playlist from which this chunk was obtained. */ + public final Uri playlistUrl; + + @Nullable private final DataSource initDataSource; + @Nullable private final DataSpec initDataSpec; + @Nullable private final Extractor previousExtractor; + + private final boolean isMasterTimestampSource; + private final boolean hasGapTag; + private final TimestampAdjuster timestampAdjuster; + private final boolean shouldSpliceIn; + private final HlsExtractorFactory extractorFactory; + @Nullable private final List<Format> muxedCaptionFormats; + @Nullable private final DrmInitData drmInitData; + private final Id3Decoder id3Decoder; + private final ParsableByteArray scratchId3Data; + private final boolean mediaSegmentEncrypted; + private final boolean initSegmentEncrypted; + + @MonotonicNonNull private Extractor extractor; + private boolean isExtractorReusable; + @MonotonicNonNull private HlsSampleStreamWrapper output; + // nextLoadPosition refers to the init segment if initDataLoadRequired is true. + // Otherwise, nextLoadPosition refers to the media segment. + private int nextLoadPosition; + private boolean initDataLoadRequired; + private volatile boolean loadCanceled; + private boolean loadCompleted; + + private HlsMediaChunk( + HlsExtractorFactory extractorFactory, + DataSource mediaDataSource, + DataSpec dataSpec, + Format format, + boolean mediaSegmentEncrypted, + @Nullable DataSource initDataSource, + @Nullable DataSpec initDataSpec, + boolean initSegmentEncrypted, + Uri playlistUrl, + @Nullable List<Format> muxedCaptionFormats, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkMediaSequence, + int discontinuitySequenceNumber, + boolean hasGapTag, + boolean isMasterTimestampSource, + TimestampAdjuster timestampAdjuster, + @Nullable DrmInitData drmInitData, + @Nullable Extractor previousExtractor, + Id3Decoder id3Decoder, + ParsableByteArray scratchId3Data, + boolean shouldSpliceIn) { + super( + mediaDataSource, + dataSpec, + format, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + chunkMediaSequence); + this.mediaSegmentEncrypted = mediaSegmentEncrypted; + this.discontinuitySequenceNumber = discontinuitySequenceNumber; + this.initDataSpec = initDataSpec; + this.initDataSource = initDataSource; + this.initDataLoadRequired = initDataSpec != null; + this.initSegmentEncrypted = initSegmentEncrypted; + this.playlistUrl = playlistUrl; + this.isMasterTimestampSource = isMasterTimestampSource; + this.timestampAdjuster = timestampAdjuster; + this.hasGapTag = hasGapTag; + this.extractorFactory = extractorFactory; + this.muxedCaptionFormats = muxedCaptionFormats; + this.drmInitData = drmInitData; + this.previousExtractor = previousExtractor; + this.id3Decoder = id3Decoder; + this.scratchId3Data = scratchId3Data; + this.shouldSpliceIn = shouldSpliceIn; + uid = uidSource.getAndIncrement(); + } + + /** + * Initializes the chunk for loading, setting the {@link HlsSampleStreamWrapper} that will receive + * samples as they are loaded. + * + * @param output The output that will receive the loaded samples. + */ + public void init(HlsSampleStreamWrapper output) { + this.output = output; + output.init(uid, shouldSpliceIn); + } + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public void load() throws IOException, InterruptedException { + // output == null means init() hasn't been called. + Assertions.checkNotNull(output); + if (extractor == null && previousExtractor != null) { + extractor = previousExtractor; + isExtractorReusable = true; + initDataLoadRequired = false; + } + maybeLoadInitData(); + if (!loadCanceled) { + if (!hasGapTag) { + loadMedia(); + } + loadCompleted = true; + } + } + + // Internal methods. + + @RequiresNonNull("output") + private void maybeLoadInitData() throws IOException, InterruptedException { + if (!initDataLoadRequired) { + return; + } + // initDataLoadRequired => initDataSource != null && initDataSpec != null + Assertions.checkNotNull(initDataSource); + Assertions.checkNotNull(initDataSpec); + feedDataToExtractor(initDataSource, initDataSpec, initSegmentEncrypted); + nextLoadPosition = 0; + initDataLoadRequired = false; + } + + @RequiresNonNull("output") + private void loadMedia() throws IOException, InterruptedException { + if (!isMasterTimestampSource) { + timestampAdjuster.waitUntilInitialized(); + } else if (timestampAdjuster.getFirstSampleTimestampUs() == TimestampAdjuster.DO_NOT_OFFSET) { + // We're the master and we haven't set the desired first sample timestamp yet. + timestampAdjuster.setFirstSampleTimestampUs(startTimeUs); + } + feedDataToExtractor(dataSource, dataSpec, mediaSegmentEncrypted); + } + + /** + * Attempts to feed the given {@code dataSpec} to {@code this.extractor}. Whenever the operation + * concludes (because of a thrown exception or because the operation finishes), the number of fed + * bytes is written to {@code nextLoadPosition}. + */ + @RequiresNonNull("output") + private void feedDataToExtractor( + DataSource dataSource, DataSpec dataSpec, boolean dataIsEncrypted) + throws IOException, InterruptedException { + // If we previously fed part of this chunk to the extractor, we need to skip it this time. For + // encrypted content we need to skip the data by reading it through the source, so as to ensure + // correct decryption of the remainder of the chunk. For clear content, we can request the + // remainder of the chunk directly. + DataSpec loadDataSpec; + boolean skipLoadedBytes; + if (dataIsEncrypted) { + loadDataSpec = dataSpec; + skipLoadedBytes = nextLoadPosition != 0; + } else { + loadDataSpec = dataSpec.subrange(nextLoadPosition); + skipLoadedBytes = false; + } + try { + ExtractorInput input = prepareExtraction(dataSource, loadDataSpec); + if (skipLoadedBytes) { + input.skipFully(nextLoadPosition); + } + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + } finally { + nextLoadPosition = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + @RequiresNonNull("output") + @EnsuresNonNull("extractor") + private DefaultExtractorInput prepareExtraction(DataSource dataSource, DataSpec dataSpec) + throws IOException, InterruptedException { + long bytesToRead = dataSource.open(dataSpec); + DefaultExtractorInput extractorInput = + new DefaultExtractorInput(dataSource, dataSpec.absoluteStreamPosition, bytesToRead); + + if (extractor == null) { + long id3Timestamp = peekId3PrivTimestamp(extractorInput); + extractorInput.resetPeekPosition(); + + HlsExtractorFactory.Result result = + extractorFactory.createExtractor( + previousExtractor, + dataSpec.uri, + trackFormat, + muxedCaptionFormats, + timestampAdjuster, + dataSource.getResponseHeaders(), + extractorInput); + extractor = result.extractor; + isExtractorReusable = result.isReusable; + if (result.isPackedAudioExtractor) { + output.setSampleOffsetUs( + id3Timestamp != C.TIME_UNSET + ? timestampAdjuster.adjustTsTimestamp(id3Timestamp) + : startTimeUs); + } else { + // In case the container format changes mid-stream to non-packed-audio, we need to reset + // the timestamp offset. + output.setSampleOffsetUs(/* sampleOffsetUs= */ 0L); + } + output.onNewExtractor(); + extractor.init(output); + } + output.setDrmInitData(drmInitData); + return extractorInput; + } + + /** + * Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined + * in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not + * found. This method only modifies the peek position. + * + * @param input The {@link ExtractorInput} to obtain the PRIV frame from. + * @return The parsed, adjusted timestamp in microseconds + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + try { + input.peekFully(scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } catch (EOFException e) { + // The input isn't long enough for there to be any ID3 data. + return C.TIME_UNSET; + } + scratchId3Data.reset(Id3Decoder.ID3_HEADER_LENGTH); + int id = scratchId3Data.readUnsignedInt24(); + if (id != Id3Decoder.ID3_TAG) { + return C.TIME_UNSET; + } + scratchId3Data.skipBytes(3); // version(2), flags(1). + int id3Size = scratchId3Data.readSynchSafeInt(); + int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH; + if (requiredCapacity > scratchId3Data.capacity()) { + byte[] data = scratchId3Data.data; + scratchId3Data.reset(requiredCapacity); + System.arraycopy(data, 0, scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } + input.peekFully(scratchId3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size); + Metadata metadata = id3Decoder.decode(scratchId3Data.data, id3Size); + if (metadata == null) { + return C.TIME_UNSET; + } + int metadataLength = metadata.length(); + for (int i = 0; i < metadataLength; i++) { + Metadata.Entry frame = metadata.get(i); + if (frame instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) frame; + if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { + System.arraycopy( + privFrame.privateData, 0, scratchId3Data.data, 0, 8 /* timestamp size */); + scratchId3Data.reset(8); + // The top 31 bits should be zeros, but explicitly zero them to wrap in the case that the + // streaming provider forgot. See: https://github.com/google/ExoPlayer/pull/3495. + return scratchId3Data.readLong() & 0x1FFFFFFFFL; + } + } + } + return C.TIME_UNSET; + } + + // Internal methods. + + private static byte[] getEncryptionIvArray(String ivString) { + String trimmedIv; + if (Util.toLowerInvariant(ivString).startsWith("0x")) { + trimmedIv = ivString.substring(2); + } else { + trimmedIv = ivString; + } + + byte[] ivData = new BigInteger(trimmedIv, /* radix= */ 16).toByteArray(); + byte[] ivDataWithPadding = new byte[16]; + int offset = ivData.length > 16 ? ivData.length - 16 : 0; + System.arraycopy( + ivData, + offset, + ivDataWithPadding, + ivDataWithPadding.length - ivData.length + offset, + ivData.length - offset); + return ivDataWithPadding; + } + + /** + * If the segment is fully encrypted, returns an {@link Aes128DataSource} that wraps the original + * in order to decrypt the loaded data. Else returns the original. + * + * <p>{@code fullSegmentEncryptionKey} & {@code encryptionIv} can either both be null, or neither. + */ + private static DataSource buildDataSource( + DataSource dataSource, + @Nullable byte[] fullSegmentEncryptionKey, + @Nullable byte[] encryptionIv) { + if (fullSegmentEncryptionKey != null) { + Assertions.checkNotNull(encryptionIv); + return new Aes128DataSource(dataSource, fullSegmentEncryptionKey, encryptionIv); + } + return dataSource; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java new file mode 100644 index 0000000000..60aa5298c3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -0,0 +1,858 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link MediaPeriod} that loads an HLS stream. + */ +public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper.Callback, + HlsPlaylistTracker.PlaylistEventListener { + + private final HlsExtractorFactory extractorFactory; + private final HlsPlaylistTracker playlistTracker; + private final HlsDataSourceFactory dataSourceFactory; + @Nullable private final TransferListener mediaTransferListener; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final Allocator allocator; + private final IdentityHashMap<SampleStream, Integer> streamWrapperIndices; + private final TimestampAdjusterProvider timestampAdjusterProvider; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final boolean allowChunklessPreparation; + private final @HlsMediaSource.MetadataType int metadataType; + private final boolean useSessionKeys; + + @Nullable private Callback callback; + private int pendingPrepareCount; + private @MonotonicNonNull TrackGroupArray trackGroups; + private HlsSampleStreamWrapper[] sampleStreamWrappers; + private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; + // Maps sample stream wrappers to variant/rendition index by matching array positions. + private int[][] manifestUrlIndicesPerWrapper; + private SequenceableLoader compositeSequenceableLoader; + private boolean notifiedReadingStarted; + + /** + * Creates an HLS media period. + * + * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the segments. + * @param playlistTracker A tracker for HLS playlists. + * @param dataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for segments + * and keys. + * @param mediaTransferListener The transfer listener to inform of any media data transfers. May + * be null if no listener is available. + * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession + * DrmSessions} with. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param compositeSequenceableLoaderFactory A factory to create composite {@link + * SequenceableLoader}s for when this media source loads data from multiple streams. + * @param allowChunklessPreparation Whether chunkless preparation is allowed. + * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags. + */ + public HlsMediaPeriod( + HlsExtractorFactory extractorFactory, + HlsPlaylistTracker playlistTracker, + HlsDataSourceFactory dataSourceFactory, + @Nullable TransferListener mediaTransferListener, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + Allocator allocator, + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + boolean allowChunklessPreparation, + @HlsMediaSource.MetadataType int metadataType, + boolean useSessionKeys) { + this.extractorFactory = extractorFactory; + this.playlistTracker = playlistTracker; + this.dataSourceFactory = dataSourceFactory; + this.mediaTransferListener = mediaTransferListener; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.allocator = allocator; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.allowChunklessPreparation = allowChunklessPreparation; + this.metadataType = metadataType; + this.useSessionKeys = useSessionKeys; + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(); + streamWrapperIndices = new IdentityHashMap<>(); + timestampAdjusterProvider = new TimestampAdjusterProvider(); + sampleStreamWrappers = new HlsSampleStreamWrapper[0]; + enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; + manifestUrlIndicesPerWrapper = new int[0][]; + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + playlistTracker.removeListener(this); + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + sampleStreamWrapper.release(); + } + callback = null; + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + playlistTracker.addListener(this); + buildAndPrepareSampleStreamWrappers(positionUs); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + sampleStreamWrapper.maybeThrowPrepareError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + // trackGroups will only be null if period hasn't been prepared or has been released. + return Assertions.checkNotNull(trackGroups); + } + + // TODO: When the master playlist does not de-duplicate variants by URL and allows Renditions with + // null URLs, this method must be updated to calculate stream keys that are compatible with those + // that may already be persisted for offline. + @Override + public List<StreamKey> getStreamKeys(List<TrackSelection> trackSelections) { + // See HlsMasterPlaylist.copy for interpretation of StreamKeys. + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + int audioWrapperOffset = hasVariants ? 1 : 0; + // Subtitle sample stream wrappers are held last. + int subtitleWrapperOffset = sampleStreamWrappers.length - masterPlaylist.subtitles.size(); + + TrackGroupArray mainWrapperTrackGroups; + int mainWrapperPrimaryGroupIndex; + int[] mainWrapperVariantIndices; + if (hasVariants) { + HlsSampleStreamWrapper mainWrapper = sampleStreamWrappers[0]; + mainWrapperVariantIndices = manifestUrlIndicesPerWrapper[0]; + mainWrapperTrackGroups = mainWrapper.getTrackGroups(); + mainWrapperPrimaryGroupIndex = mainWrapper.getPrimaryTrackGroupIndex(); + } else { + mainWrapperVariantIndices = new int[0]; + mainWrapperTrackGroups = TrackGroupArray.EMPTY; + mainWrapperPrimaryGroupIndex = 0; + } + + List<StreamKey> streamKeys = new ArrayList<>(); + boolean needsPrimaryTrackGroupSelection = false; + boolean hasPrimaryTrackGroupSelection = false; + for (TrackSelection trackSelection : trackSelections) { + TrackGroup trackSelectionGroup = trackSelection.getTrackGroup(); + int mainWrapperTrackGroupIndex = mainWrapperTrackGroups.indexOf(trackSelectionGroup); + if (mainWrapperTrackGroupIndex != C.INDEX_UNSET) { + if (mainWrapperTrackGroupIndex == mainWrapperPrimaryGroupIndex) { + // Primary group in main wrapper. + hasPrimaryTrackGroupSelection = true; + for (int i = 0; i < trackSelection.length(); i++) { + int variantIndex = mainWrapperVariantIndices[trackSelection.getIndexInTrackGroup(i)]; + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, variantIndex)); + } + } else { + // Embedded group in main wrapper. + needsPrimaryTrackGroupSelection = true; + } + } else { + // Audio or subtitle group. + for (int i = audioWrapperOffset; i < sampleStreamWrappers.length; i++) { + TrackGroupArray wrapperTrackGroups = sampleStreamWrappers[i].getTrackGroups(); + int selectedTrackGroupIndex = wrapperTrackGroups.indexOf(trackSelectionGroup); + if (selectedTrackGroupIndex != C.INDEX_UNSET) { + int groupIndexType = + i < subtitleWrapperOffset + ? HlsMasterPlaylist.GROUP_INDEX_AUDIO + : HlsMasterPlaylist.GROUP_INDEX_SUBTITLE; + int[] selectedWrapperUrlIndices = manifestUrlIndicesPerWrapper[i]; + for (int trackIndex = 0; trackIndex < trackSelection.length(); trackIndex++) { + int renditionIndex = + selectedWrapperUrlIndices[trackSelection.getIndexInTrackGroup(trackIndex)]; + streamKeys.add(new StreamKey(groupIndexType, renditionIndex)); + } + break; + } + } + } + } + if (needsPrimaryTrackGroupSelection && !hasPrimaryTrackGroupSelection) { + // A track selection includes a variant-embedded track, but no variant is added yet. We use + // the valid variant with the lowest bitrate to reduce overhead. + int lowestBitrateIndex = mainWrapperVariantIndices[0]; + int lowestBitrate = masterPlaylist.variants.get(mainWrapperVariantIndices[0]).format.bitrate; + for (int i = 1; i < mainWrapperVariantIndices.length; i++) { + int variantBitrate = + masterPlaylist.variants.get(mainWrapperVariantIndices[i]).format.bitrate; + if (variantBitrate < lowestBitrate) { + lowestBitrate = variantBitrate; + lowestBitrateIndex = mainWrapperVariantIndices[i]; + } + } + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, lowestBitrateIndex)); + } + return streamKeys; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + // Map each selection and stream onto a child period index. + int[] streamChildIndices = new int[selections.length]; + int[] selectionChildIndices = new int[selections.length]; + for (int i = 0; i < selections.length; i++) { + streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET + : streamWrapperIndices.get(streams[i]); + selectionChildIndices[i] = C.INDEX_UNSET; + if (selections[i] != null) { + TrackGroup trackGroup = selections[i].getTrackGroup(); + for (int j = 0; j < sampleStreamWrappers.length; j++) { + if (sampleStreamWrappers[j].getTrackGroups().indexOf(trackGroup) != C.INDEX_UNSET) { + selectionChildIndices[i] = j; + break; + } + } + } + } + + boolean forceReset = false; + streamWrapperIndices.clear(); + // Select tracks for each child, copying the resulting streams back into a new streams array. + SampleStream[] newStreams = new SampleStream[selections.length]; + @NullableType SampleStream[] childStreams = new SampleStream[selections.length]; + @NullableType TrackSelection[] childSelections = new TrackSelection[selections.length]; + int newEnabledSampleStreamWrapperCount = 0; + HlsSampleStreamWrapper[] newEnabledSampleStreamWrappers = + new HlsSampleStreamWrapper[sampleStreamWrappers.length]; + for (int i = 0; i < sampleStreamWrappers.length; i++) { + for (int j = 0; j < selections.length; j++) { + childStreams[j] = streamChildIndices[j] == i ? streams[j] : null; + childSelections[j] = selectionChildIndices[j] == i ? selections[j] : null; + } + HlsSampleStreamWrapper sampleStreamWrapper = sampleStreamWrappers[i]; + boolean wasReset = sampleStreamWrapper.selectTracks(childSelections, mayRetainStreamFlags, + childStreams, streamResetFlags, positionUs, forceReset); + boolean wrapperEnabled = false; + for (int j = 0; j < selections.length; j++) { + SampleStream childStream = childStreams[j]; + if (selectionChildIndices[j] == i) { + // Assert that the child provided a stream for the selection. + Assertions.checkNotNull(childStream); + newStreams[j] = childStream; + wrapperEnabled = true; + streamWrapperIndices.put(childStream, i); + } else if (streamChildIndices[j] == i) { + // Assert that the child cleared any previous stream. + Assertions.checkState(childStream == null); + } + } + if (wrapperEnabled) { + newEnabledSampleStreamWrappers[newEnabledSampleStreamWrapperCount] = sampleStreamWrapper; + if (newEnabledSampleStreamWrapperCount++ == 0) { + // The first enabled wrapper is responsible for initializing timestamp adjusters. This + // way, if enabled, variants are responsible. Else audio renditions. Else text renditions. + sampleStreamWrapper.setIsTimestampMaster(true); + if (wasReset || enabledSampleStreamWrappers.length == 0 + || sampleStreamWrapper != enabledSampleStreamWrappers[0]) { + // The wrapper responsible for initializing the timestamp adjusters was reset or + // changed. We need to reset the timestamp adjuster provider and all other wrappers. + timestampAdjusterProvider.reset(); + forceReset = true; + } + } else { + sampleStreamWrapper.setIsTimestampMaster(false); + } + } + } + // Copy the new streams back into the streams array. + System.arraycopy(newStreams, 0, streams, 0, newStreams.length); + // Update the local state. + enabledSampleStreamWrappers = + Util.nullSafeArrayCopy(newEnabledSampleStreamWrappers, newEnabledSampleStreamWrapperCount); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader( + enabledSampleStreamWrappers); + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) { + sampleStreamWrapper.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + if (trackGroups == null) { + // Preparation is still going on. + for (HlsSampleStreamWrapper wrapper : sampleStreamWrappers) { + wrapper.continuePreparing(); + } + return false; + } else { + return compositeSequenceableLoader.continueLoading(positionUs); + } + } + + @Override + public boolean isLoading() { + return compositeSequenceableLoader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeSequenceableLoader.getNextLoadPositionUs(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + return C.TIME_UNSET; + } + + @Override + public long getBufferedPositionUs() { + return compositeSequenceableLoader.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + if (enabledSampleStreamWrappers.length > 0) { + // We need to reset all wrappers if the one responsible for initializing timestamp adjusters + // is reset. Else each wrapper can decide whether to reset independently. + boolean forceReset = enabledSampleStreamWrappers[0].seekToUs(positionUs, false); + for (int i = 1; i < enabledSampleStreamWrappers.length; i++) { + enabledSampleStreamWrappers[i].seekToUs(positionUs, forceReset); + } + if (forceReset) { + timestampAdjusterProvider.reset(); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + // HlsSampleStreamWrapper.Callback implementation. + + @Override + public void onPrepared() { + if (--pendingPrepareCount > 0) { + return; + } + + int totalTrackGroupCount = 0; + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + totalTrackGroupCount += sampleStreamWrapper.getTrackGroups().length; + } + TrackGroup[] trackGroupArray = new TrackGroup[totalTrackGroupCount]; + int trackGroupIndex = 0; + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + int wrapperTrackGroupCount = sampleStreamWrapper.getTrackGroups().length; + for (int j = 0; j < wrapperTrackGroupCount; j++) { + trackGroupArray[trackGroupIndex++] = sampleStreamWrapper.getTrackGroups().get(j); + } + } + trackGroups = new TrackGroupArray(trackGroupArray); + callback.onPrepared(this); + } + + @Override + public void onPlaylistRefreshRequired(Uri url) { + playlistTracker.refreshPlaylist(url); + } + + @Override + public void onContinueLoadingRequested(HlsSampleStreamWrapper sampleStreamWrapper) { + callback.onContinueLoadingRequested(this); + } + + // PlaylistListener implementation. + + @Override + public void onPlaylistChanged() { + callback.onContinueLoadingRequested(this); + } + + @Override + public boolean onPlaylistError(Uri url, long blacklistDurationMs) { + boolean noBlacklistingFailure = true; + for (HlsSampleStreamWrapper streamWrapper : sampleStreamWrappers) { + noBlacklistingFailure &= streamWrapper.onPlaylistError(url, blacklistDurationMs); + } + callback.onContinueLoadingRequested(this); + return noBlacklistingFailure; + } + + // Internal methods. + + private void buildAndPrepareSampleStreamWrappers(long positionUs) { + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + Map<String, DrmInitData> overridingDrmInitData = + useSessionKeys + ? deriveOverridingDrmInitData(masterPlaylist.sessionKeyDrmInitData) + : Collections.emptyMap(); + + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + List<Rendition> audioRenditions = masterPlaylist.audios; + List<Rendition> subtitleRenditions = masterPlaylist.subtitles; + + pendingPrepareCount = 0; + ArrayList<HlsSampleStreamWrapper> sampleStreamWrappers = new ArrayList<>(); + ArrayList<int[]> manifestUrlIndicesPerWrapper = new ArrayList<>(); + + if (hasVariants) { + buildAndPrepareMainSampleStreamWrapper( + masterPlaylist, + positionUs, + sampleStreamWrappers, + manifestUrlIndicesPerWrapper, + overridingDrmInitData); + } + + // TODO: Build video stream wrappers here. + + buildAndPrepareAudioSampleStreamWrappers( + positionUs, + audioRenditions, + sampleStreamWrappers, + manifestUrlIndicesPerWrapper, + overridingDrmInitData); + + // Subtitle stream wrappers. We can always use master playlist information to prepare these. + for (int i = 0; i < subtitleRenditions.size(); i++) { + Rendition subtitleRendition = subtitleRenditions.get(i); + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_TEXT, + new Uri[] {subtitleRendition.url}, + new Format[] {subtitleRendition.format}, + null, + Collections.emptyList(), + overridingDrmInitData, + positionUs); + manifestUrlIndicesPerWrapper.add(new int[] {i}); + sampleStreamWrappers.add(sampleStreamWrapper); + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + new TrackGroup[] {new TrackGroup(subtitleRendition.format)}, + /* primaryTrackGroupIndex= */ 0); + } + + this.sampleStreamWrappers = sampleStreamWrappers.toArray(new HlsSampleStreamWrapper[0]); + this.manifestUrlIndicesPerWrapper = manifestUrlIndicesPerWrapper.toArray(new int[0][]); + pendingPrepareCount = this.sampleStreamWrappers.length; + // Set timestamp master and trigger preparation (if not already prepared) + this.sampleStreamWrappers[0].setIsTimestampMaster(true); + for (HlsSampleStreamWrapper sampleStreamWrapper : this.sampleStreamWrappers) { + sampleStreamWrapper.continuePreparing(); + } + // All wrappers are enabled during preparation. + enabledSampleStreamWrappers = this.sampleStreamWrappers; + } + + /** + * This method creates and starts preparation of the main {@link HlsSampleStreamWrapper}. + * + * <p>The main sample stream wrapper is the first element of {@link #sampleStreamWrappers}. It + * provides {@link SampleStream}s for the variant urls in the master playlist. It may be adaptive + * and may contain multiple muxed tracks. + * + * <p>If chunkless preparation is allowed, the media period will try preparation without segment + * downloads. This is only possible if variants contain the CODECS attribute. If not, traditional + * preparation with segment downloads will take place. The following points apply to chunkless + * preparation: + * + * <ul> + * <li>A muxed audio track will be exposed if the codecs list contain an audio entry and the + * master playlist either contains an EXT-X-MEDIA tag without the URI attribute or does not + * contain any EXT-X-MEDIA tag. + * <li>Closed captions will only be exposed if they are declared by the master playlist. + * <li>An ID3 track is exposed preemptively, in case the segments contain an ID3 track. + * </ul> + * + * @param masterPlaylist The HLS master playlist. + * @param positionUs If preparation requires any chunk downloads, the position in microseconds at + * which downloading should start. Ignored otherwise. + * @param sampleStreamWrappers List to which the built main sample stream wrapper should be added. + * @param manifestUrlIndicesPerWrapper List to which the selected variant indices should be added. + * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type + * (i.e. {@link DrmInitData#schemeType}). + */ + private void buildAndPrepareMainSampleStreamWrapper( + HlsMasterPlaylist masterPlaylist, + long positionUs, + List<HlsSampleStreamWrapper> sampleStreamWrappers, + List<int[]> manifestUrlIndicesPerWrapper, + Map<String, DrmInitData> overridingDrmInitData) { + int[] variantTypes = new int[masterPlaylist.variants.size()]; + int videoVariantCount = 0; + int audioVariantCount = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + Variant variant = masterPlaylist.variants.get(i); + Format format = variant.format; + if (format.height > 0 || Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_VIDEO) != null) { + variantTypes[i] = C.TRACK_TYPE_VIDEO; + videoVariantCount++; + } else if (Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_AUDIO) != null) { + variantTypes[i] = C.TRACK_TYPE_AUDIO; + audioVariantCount++; + } else { + variantTypes[i] = C.TRACK_TYPE_UNKNOWN; + } + } + boolean useVideoVariantsOnly = false; + boolean useNonAudioVariantsOnly = false; + int selectedVariantsCount = variantTypes.length; + if (videoVariantCount > 0) { + // We've identified some variants as definitely containing video. Assume variants within the + // master playlist are marked consistently, and hence that we have the full set. Filter out + // any other variants, which are likely to be audio only. + useVideoVariantsOnly = true; + selectedVariantsCount = videoVariantCount; + } else if (audioVariantCount < variantTypes.length) { + // We've identified some variants, but not all, as being audio only. Filter them out to leave + // the remaining variants, which are likely to contain video. + useNonAudioVariantsOnly = true; + selectedVariantsCount = variantTypes.length - audioVariantCount; + } + Uri[] selectedPlaylistUrls = new Uri[selectedVariantsCount]; + Format[] selectedPlaylistFormats = new Format[selectedVariantsCount]; + int[] selectedVariantIndices = new int[selectedVariantsCount]; + int outIndex = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + if ((!useVideoVariantsOnly || variantTypes[i] == C.TRACK_TYPE_VIDEO) + && (!useNonAudioVariantsOnly || variantTypes[i] != C.TRACK_TYPE_AUDIO)) { + Variant variant = masterPlaylist.variants.get(i); + selectedPlaylistUrls[outIndex] = variant.url; + selectedPlaylistFormats[outIndex] = variant.format; + selectedVariantIndices[outIndex++] = i; + } + } + String codecs = selectedPlaylistFormats[0].codecs; + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_DEFAULT, + selectedPlaylistUrls, + selectedPlaylistFormats, + masterPlaylist.muxedAudioFormat, + masterPlaylist.muxedCaptionFormats, + overridingDrmInitData, + positionUs); + sampleStreamWrappers.add(sampleStreamWrapper); + manifestUrlIndicesPerWrapper.add(selectedVariantIndices); + if (allowChunklessPreparation && codecs != null) { + boolean variantsContainVideoCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_VIDEO) != null; + boolean variantsContainAudioCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_AUDIO) != null; + List<TrackGroup> muxedTrackGroups = new ArrayList<>(); + if (variantsContainVideoCodecs) { + Format[] videoFormats = new Format[selectedVariantsCount]; + for (int i = 0; i < videoFormats.length; i++) { + videoFormats[i] = deriveVideoFormat(selectedPlaylistFormats[i]); + } + muxedTrackGroups.add(new TrackGroup(videoFormats)); + + if (variantsContainAudioCodecs + && (masterPlaylist.muxedAudioFormat != null || masterPlaylist.audios.isEmpty())) { + muxedTrackGroups.add( + new TrackGroup( + deriveAudioFormat( + selectedPlaylistFormats[0], + masterPlaylist.muxedAudioFormat, + /* isPrimaryTrackInVariant= */ false))); + } + List<Format> ccFormats = masterPlaylist.muxedCaptionFormats; + if (ccFormats != null) { + for (int i = 0; i < ccFormats.size(); i++) { + muxedTrackGroups.add(new TrackGroup(ccFormats.get(i))); + } + } + } else if (variantsContainAudioCodecs) { + // Variants only contain audio. + Format[] audioFormats = new Format[selectedVariantsCount]; + for (int i = 0; i < audioFormats.length; i++) { + audioFormats[i] = + deriveAudioFormat( + /* variantFormat= */ selectedPlaylistFormats[i], + masterPlaylist.muxedAudioFormat, + /* isPrimaryTrackInVariant= */ true); + } + muxedTrackGroups.add(new TrackGroup(audioFormats)); + } else { + // Variants contain codecs but no video or audio entries could be identified. + throw new IllegalArgumentException("Unexpected codecs attribute: " + codecs); + } + + TrackGroup id3TrackGroup = + new TrackGroup( + Format.createSampleFormat( + /* id= */ "ID3", + MimeTypes.APPLICATION_ID3, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* drmInitData= */ null)); + muxedTrackGroups.add(id3TrackGroup); + + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + muxedTrackGroups.toArray(new TrackGroup[0]), + /* primaryTrackGroupIndex= */ 0, + /* optionalTrackGroupsIndices= */ muxedTrackGroups.indexOf(id3TrackGroup)); + } + } + + private void buildAndPrepareAudioSampleStreamWrappers( + long positionUs, + List<Rendition> audioRenditions, + List<HlsSampleStreamWrapper> sampleStreamWrappers, + List<int[]> manifestUrlsIndicesPerWrapper, + Map<String, DrmInitData> overridingDrmInitData) { + ArrayList<Uri> scratchPlaylistUrls = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + ArrayList<Format> scratchPlaylistFormats = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + ArrayList<Integer> scratchIndicesList = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + HashSet<String> alreadyGroupedNames = new HashSet<>(); + for (int renditionByNameIndex = 0; + renditionByNameIndex < audioRenditions.size(); + renditionByNameIndex++) { + String name = audioRenditions.get(renditionByNameIndex).name; + if (!alreadyGroupedNames.add(name)) { + // This name already has a corresponding group. + continue; + } + + boolean renditionsHaveCodecs = true; + scratchPlaylistUrls.clear(); + scratchPlaylistFormats.clear(); + scratchIndicesList.clear(); + // Group all renditions with matching name. + for (int renditionIndex = 0; renditionIndex < audioRenditions.size(); renditionIndex++) { + if (Util.areEqual(name, audioRenditions.get(renditionIndex).name)) { + Rendition rendition = audioRenditions.get(renditionIndex); + scratchIndicesList.add(renditionIndex); + scratchPlaylistUrls.add(rendition.url); + scratchPlaylistFormats.add(rendition.format); + renditionsHaveCodecs &= rendition.format.codecs != null; + } + } + + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_AUDIO, + scratchPlaylistUrls.toArray(Util.castNonNullTypeArray(new Uri[0])), + scratchPlaylistFormats.toArray(new Format[0]), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + overridingDrmInitData, + positionUs); + manifestUrlsIndicesPerWrapper.add(Util.toArray(scratchIndicesList)); + sampleStreamWrappers.add(sampleStreamWrapper); + + if (allowChunklessPreparation && renditionsHaveCodecs) { + Format[] renditionFormats = scratchPlaylistFormats.toArray(new Format[0]); + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + new TrackGroup[] {new TrackGroup(renditionFormats)}, /* primaryTrackGroupIndex= */ 0); + } + } + } + + private HlsSampleStreamWrapper buildSampleStreamWrapper( + int trackType, + Uri[] playlistUrls, + Format[] playlistFormats, + @Nullable Format muxedAudioFormat, + @Nullable List<Format> muxedCaptionFormats, + Map<String, DrmInitData> overridingDrmInitData, + long positionUs) { + HlsChunkSource defaultChunkSource = + new HlsChunkSource( + extractorFactory, + playlistTracker, + playlistUrls, + playlistFormats, + dataSourceFactory, + mediaTransferListener, + timestampAdjusterProvider, + muxedCaptionFormats); + return new HlsSampleStreamWrapper( + trackType, + /* callback= */ this, + defaultChunkSource, + overridingDrmInitData, + allocator, + positionUs, + muxedAudioFormat, + drmSessionManager, + loadErrorHandlingPolicy, + eventDispatcher, + metadataType); + } + + private static Map<String, DrmInitData> deriveOverridingDrmInitData( + List<DrmInitData> sessionKeyDrmInitData) { + ArrayList<DrmInitData> mutableSessionKeyDrmInitData = new ArrayList<>(sessionKeyDrmInitData); + HashMap<String, DrmInitData> drmInitDataBySchemeType = new HashMap<>(); + for (int i = 0; i < mutableSessionKeyDrmInitData.size(); i++) { + DrmInitData drmInitData = sessionKeyDrmInitData.get(i); + String scheme = drmInitData.schemeType; + // Merge any subsequent drmInitData instances that have the same scheme type. This is valid + // due to the assumptions documented on HlsMediaSource.Builder.setUseSessionKeys, and is + // necessary to get data for different CDNs (e.g. Widevine and PlayReady) into a single + // drmInitData. + int j = i + 1; + while (j < mutableSessionKeyDrmInitData.size()) { + DrmInitData nextDrmInitData = mutableSessionKeyDrmInitData.get(j); + if (TextUtils.equals(nextDrmInitData.schemeType, scheme)) { + drmInitData = drmInitData.merge(nextDrmInitData); + mutableSessionKeyDrmInitData.remove(j); + } else { + j++; + } + } + drmInitDataBySchemeType.put(scheme, drmInitData); + } + return drmInitDataBySchemeType; + } + + private static Format deriveVideoFormat(Format variantFormat) { + String codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO); + String sampleMimeType = MimeTypes.getMediaMimeType(codecs); + return Format.createVideoContainerFormat( + variantFormat.id, + variantFormat.label, + variantFormat.containerMimeType, + sampleMimeType, + codecs, + variantFormat.metadata, + variantFormat.bitrate, + variantFormat.width, + variantFormat.height, + variantFormat.frameRate, + /* initializationData= */ null, + variantFormat.selectionFlags, + variantFormat.roleFlags); + } + + private static Format deriveAudioFormat( + Format variantFormat, @Nullable Format mediaTagFormat, boolean isPrimaryTrackInVariant) { + String codecs; + Metadata metadata; + int channelCount = Format.NO_VALUE; + int selectionFlags = 0; + int roleFlags = 0; + String language = null; + String label = null; + if (mediaTagFormat != null) { + codecs = mediaTagFormat.codecs; + metadata = mediaTagFormat.metadata; + channelCount = mediaTagFormat.channelCount; + selectionFlags = mediaTagFormat.selectionFlags; + roleFlags = mediaTagFormat.roleFlags; + language = mediaTagFormat.language; + label = mediaTagFormat.label; + } else { + codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_AUDIO); + metadata = variantFormat.metadata; + if (isPrimaryTrackInVariant) { + channelCount = variantFormat.channelCount; + selectionFlags = variantFormat.selectionFlags; + roleFlags = variantFormat.roleFlags; + language = variantFormat.language; + label = variantFormat.label; + } + } + String sampleMimeType = MimeTypes.getMediaMimeType(codecs); + int bitrate = isPrimaryTrackInVariant ? variantFormat.bitrate : Format.NO_VALUE; + return Format.createAudioContainerFormat( + variantFormat.id, + label, + variantFormat.containerMimeType, + sampleMimeType, + codecs, + metadata, + bitrate, + channelCount, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + selectionFlags, + roleFlags, + language); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java new file mode 100644 index 0000000000..2fa49e13f0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -0,0 +1,528 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BaseMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SinglePeriodTimeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.FilteringHlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.util.List; + +/** An HLS {@link MediaSource}. */ +public final class HlsMediaSource extends BaseMediaSource + implements HlsPlaylistTracker.PrimaryPlaylistListener { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.hls"); + } + + /** + * The types of metadata that can be extracted from HLS streams. + * + * <p>Allowed values: + * + * <ul> + * <li>{@link #METADATA_TYPE_ID3} + * <li>{@link #METADATA_TYPE_EMSG} + * </ul> + * + * <p>See {@link Factory#setMetadataType(int)}. + */ + @Documented + @Retention(SOURCE) + @IntDef({METADATA_TYPE_ID3, METADATA_TYPE_EMSG}) + public @interface MetadataType {} + + /** Type for ID3 metadata in HLS streams. */ + public static final int METADATA_TYPE_ID3 = 1; + /** Type for ESMG metadata in HLS streams. */ + public static final int METADATA_TYPE_EMSG = 3; + + /** Factory for {@link HlsMediaSource}s. */ + public static final class Factory implements MediaSourceFactory { + + private final HlsDataSourceFactory hlsDataSourceFactory; + + private HlsExtractorFactory extractorFactory; + private HlsPlaylistParserFactory playlistParserFactory; + @Nullable private List<StreamKey> streamKeys; + private HlsPlaylistTracker.Factory playlistTrackerFactory; + private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private DrmSessionManager<?> drmSessionManager; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private boolean allowChunklessPreparation; + @MetadataType private int metadataType; + private boolean useSessionKeys; + private boolean isCreateCalled; + @Nullable private Object tag; + + /** + * Creates a new factory for {@link HlsMediaSource}s. + * + * @param dataSourceFactory A data source factory that will be wrapped by a {@link + * DefaultHlsDataSourceFactory} to create {@link DataSource}s for manifests, segments and + * keys. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this(new DefaultHlsDataSourceFactory(dataSourceFactory)); + } + + /** + * Creates a new factory for {@link HlsMediaSource}s. + * + * @param hlsDataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for + * manifests, segments and keys. + */ + public Factory(HlsDataSourceFactory hlsDataSourceFactory) { + this.hlsDataSourceFactory = Assertions.checkNotNull(hlsDataSourceFactory); + playlistParserFactory = new DefaultHlsPlaylistParserFactory(); + playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY; + extractorFactory = HlsExtractorFactory.DEFAULT; + drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); + metadataType = METADATA_TYPE_ID3; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline} of the source as {@link + * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(@Nullable Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the factory for {@link Extractor}s for the segments. The default value is {@link + * HlsExtractorFactory#DEFAULT}. + * + * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the + * segments. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setExtractorFactory(HlsExtractorFactory extractorFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorFactory = Assertions.checkNotNull(extractorFactory); + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. The default value is + * {@link DefaultLoadErrorHandlingPolicy#DEFAULT_MIN_LOADABLE_RETRY_COUNT}. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount); + return this; + } + + /** + * Sets the factory from which playlist parsers will be obtained. The default value is a {@link + * DefaultHlsPlaylistParserFactory}. + * + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setPlaylistParserFactory(HlsPlaylistParserFactory playlistParserFactory) { + Assertions.checkState(!isCreateCalled); + this.playlistParserFactory = Assertions.checkNotNull(playlistParserFactory); + return this; + } + + /** + * Sets the {@link HlsPlaylistTracker} factory. The default value is {@link + * DefaultHlsPlaylistTracker#FACTORY}. + * + * @param playlistTrackerFactory A factory for {@link HlsPlaylistTracker} instances. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setPlaylistTrackerFactory(HlsPlaylistTracker.Factory playlistTrackerFactory) { + Assertions.checkState(!isCreateCalled); + this.playlistTrackerFactory = Assertions.checkNotNull(playlistTrackerFactory); + return this; + } + + /** + * Sets the factory to create composite {@link SequenceableLoader}s for when this media source + * loads data from multiple streams (video, audio etc...). The default is an instance of {@link + * DefaultCompositeSequenceableLoaderFactory}. + * + * @param compositeSequenceableLoaderFactory A factory to create composite {@link + * SequenceableLoader}s for when this media source loads data from multiple streams (video, + * audio etc...). + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setCompositeSequenceableLoaderFactory( + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory) { + Assertions.checkState(!isCreateCalled); + this.compositeSequenceableLoaderFactory = + Assertions.checkNotNull(compositeSequenceableLoaderFactory); + return this; + } + + /** + * Sets whether chunkless preparation is allowed. If true, preparation without chunk downloads + * will be enabled for streams that provide sufficient information in their master playlist. + * + * @param allowChunklessPreparation Whether chunkless preparation is allowed. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setAllowChunklessPreparation(boolean allowChunklessPreparation) { + Assertions.checkState(!isCreateCalled); + this.allowChunklessPreparation = allowChunklessPreparation; + return this; + } + + /** + * Sets the type of metadata to extract from the HLS source (defaults to {@link + * #METADATA_TYPE_ID3}). + * + * <p>HLS supports in-band ID3 in both TS and fMP4 streams, but in the fMP4 case the data is + * wrapped in an EMSG box [<a href="https://aomediacodec.github.io/av1-id3/">spec</a>]. + * + * <p>If this is set to {@link #METADATA_TYPE_ID3} then raw ID3 metadata of will be extracted + * from TS sources. From fMP4 streams EMSGs containing metadata of this type (in the variant + * stream only) will be unwrapped to expose the inner data. All other in-band metadata will be + * dropped. + * + * <p>If this is set to {@link #METADATA_TYPE_EMSG} then all EMSG data from the fMP4 variant + * stream will be extracted. No metadata will be extracted from TS streams, since they don't + * support EMSG. + * + * @param metadataType The type of metadata to extract. + * @return This factory, for convenience. + */ + public Factory setMetadataType(@MetadataType int metadataType) { + Assertions.checkState(!isCreateCalled); + this.metadataType = metadataType; + return this; + } + + /** + * Sets whether to use #EXT-X-SESSION-KEY tags provided in the master playlist. If enabled, it's + * assumed that any single session key declared in the master playlist can be used to obtain all + * of the keys required for playback. For media where this is not true, this option should not + * be enabled. + * + * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags. + * @return This factory, for convenience. + */ + public Factory setUseSessionKeys(boolean useSessionKeys) { + this.useSessionKeys = useSessionKeys; + return this; + } + + /** + * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * MediaSourceEventListener)} instead. + */ + @Deprecated + public HlsMediaSource createMediaSource( + Uri playlistUri, + @Nullable Handler eventHandler, + @Nullable MediaSourceEventListener eventListener) { + HlsMediaSource mediaSource = createMediaSource(playlistUri); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + + /** + * Returns a new {@link HlsMediaSource} using the current parameters. + * + * @return The new {@link HlsMediaSource}. + */ + @Override + public HlsMediaSource createMediaSource(Uri playlistUri) { + isCreateCalled = true; + if (streamKeys != null) { + playlistParserFactory = + new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys); + } + return new HlsMediaSource( + playlistUri, + hlsDataSourceFactory, + extractorFactory, + compositeSequenceableLoaderFactory, + drmSessionManager, + loadErrorHandlingPolicy, + playlistTrackerFactory.createTracker( + hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory), + allowChunklessPreparation, + metadataType, + useSessionKeys, + tag); + } + + @Override + public Factory setStreamKeys(List<StreamKey> streamKeys) { + Assertions.checkState(!isCreateCalled); + this.streamKeys = streamKeys; + return this; + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_HLS}; + } + + } + + private final HlsExtractorFactory extractorFactory; + private final Uri manifestUri; + private final HlsDataSourceFactory dataSourceFactory; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final boolean allowChunklessPreparation; + private final @MetadataType int metadataType; + private final boolean useSessionKeys; + private final HlsPlaylistTracker playlistTracker; + @Nullable private final Object tag; + + @Nullable private TransferListener mediaTransferListener; + + private HlsMediaSource( + Uri manifestUri, + HlsDataSourceFactory dataSourceFactory, + HlsExtractorFactory extractorFactory, + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistTracker playlistTracker, + boolean allowChunklessPreparation, + @MetadataType int metadataType, + boolean useSessionKeys, + @Nullable Object tag) { + this.manifestUri = manifestUri; + this.dataSourceFactory = dataSourceFactory; + this.extractorFactory = extractorFactory; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playlistTracker = playlistTracker; + this.allowChunklessPreparation = allowChunklessPreparation; + this.metadataType = metadataType; + this.useSessionKeys = useSessionKeys; + this.tag = tag; + } + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + this.mediaTransferListener = mediaTransferListener; + drmSessionManager.prepare(); + EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + playlistTracker.start(manifestUri, eventDispatcher, /* listener= */ this); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + playlistTracker.maybeThrowPrimaryPlaylistRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + EventDispatcher eventDispatcher = createEventDispatcher(id); + return new HlsMediaPeriod( + extractorFactory, + playlistTracker, + dataSourceFactory, + mediaTransferListener, + drmSessionManager, + loadErrorHandlingPolicy, + eventDispatcher, + allocator, + compositeSequenceableLoaderFactory, + allowChunklessPreparation, + metadataType, + useSessionKeys); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((HlsMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + playlistTracker.stop(); + drmSessionManager.release(); + } + + @Override + public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { + SinglePeriodTimeline timeline; + long windowStartTimeMs = playlist.hasProgramDateTime ? C.usToMs(playlist.startTimeUs) + : C.TIME_UNSET; + // For playlist types EVENT and VOD we know segments are never removed, so the presentation + // started at the same time as the window. Otherwise, we don't know the presentation start time. + long presentationStartTimeMs = + playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT + || playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD + ? windowStartTimeMs + : C.TIME_UNSET; + long windowDefaultStartPositionUs = playlist.startOffsetUs; + // masterPlaylist is non-null because the first playlist has been fetched by now. + HlsManifest manifest = + new HlsManifest(Assertions.checkNotNull(playlistTracker.getMasterPlaylist()), playlist); + if (playlistTracker.isLive()) { + long offsetFromInitialStartTimeUs = + playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + long periodDurationUs = + playlist.hasEndTag ? offsetFromInitialStartTimeUs + playlist.durationUs : C.TIME_UNSET; + List<HlsMediaPlaylist.Segment> segments = playlist.segments; + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = 0; + if (!segments.isEmpty()) { + int defaultStartSegmentIndex = Math.max(0, segments.size() - 3); + // We attempt to set the default start position to be at least twice the target duration + // behind the live edge. + long minStartPositionUs = playlist.durationUs - playlist.targetDurationUs * 2; + while (defaultStartSegmentIndex > 0 + && segments.get(defaultStartSegmentIndex).relativeStartTimeUs > minStartPositionUs) { + defaultStartSegmentIndex--; + } + windowDefaultStartPositionUs = segments.get(defaultStartSegmentIndex).relativeStartTimeUs; + } + } + timeline = + new SinglePeriodTimeline( + presentationStartTimeMs, + windowStartTimeMs, + periodDurationUs, + /* windowDurationUs= */ playlist.durationUs, + /* windowPositionInPeriodUs= */ offsetFromInitialStartTimeUs, + windowDefaultStartPositionUs, + /* isSeekable= */ true, + /* isDynamic= */ !playlist.hasEndTag, + /* isLive= */ true, + manifest, + tag); + } else /* not live */ { + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = 0; + } + timeline = + new SinglePeriodTimeline( + presentationStartTimeMs, + windowStartTimeMs, + /* periodDurationUs= */ playlist.durationUs, + /* windowDurationUs= */ playlist.durationUs, + /* windowPositionInPeriodUs= */ 0, + windowDefaultStartPositionUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + manifest, + tag); + } + refreshSourceInfo(timeline); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java new file mode 100644 index 0000000000..5f44810af5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * {@link SampleStream} for a particular sample queue in HLS. + */ +/* package */ final class HlsSampleStream implements SampleStream { + + private final int trackGroupIndex; + private final HlsSampleStreamWrapper sampleStreamWrapper; + private int sampleQueueIndex; + + public HlsSampleStream(HlsSampleStreamWrapper sampleStreamWrapper, int trackGroupIndex) { + this.sampleStreamWrapper = sampleStreamWrapper; + this.trackGroupIndex = trackGroupIndex; + sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING; + } + + public void bindSampleQueue() { + Assertions.checkArgument(sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING); + sampleQueueIndex = sampleStreamWrapper.bindSampleQueueToSampleStream(trackGroupIndex); + } + + public void unbindSampleQueue() { + if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) { + sampleStreamWrapper.unbindSampleQueue(trackGroupIndex); + sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING; + } + } + + // SampleStream implementation. + + @Override + public boolean isReady() { + return sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + || (hasValidSampleQueueIndex() && sampleStreamWrapper.isReady(sampleQueueIndex)); + } + + @Override + public void maybeThrowError() throws IOException { + if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL) { + throw new SampleQueueMappingException( + sampleStreamWrapper.getTrackGroups().get(trackGroupIndex).getFormat(0).sampleMimeType); + } else if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) { + sampleStreamWrapper.maybeThrowError(); + } else if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) { + sampleStreamWrapper.maybeThrowError(sampleQueueIndex); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean requireFormat) { + if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + return hasValidSampleQueueIndex() + ? sampleStreamWrapper.readData(sampleQueueIndex, formatHolder, buffer, requireFormat) + : C.RESULT_NOTHING_READ; + } + + @Override + public int skipData(long positionUs) { + return hasValidSampleQueueIndex() + ? sampleStreamWrapper.skipData(sampleQueueIndex, positionUs) + : 0; + } + + // Internal methods. + + private boolean hasValidSampleQueueIndex() { + return sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING + && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java new file mode 100644 index 0000000000..833abbc29f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -0,0 +1,1535 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.os.Handler; +import android.util.SparseIntArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessage; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.Chunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Loads {@link HlsMediaChunk}s obtained from a {@link HlsChunkSource}, and provides + * {@link SampleStream}s from which the loaded media can be consumed. + */ +/* package */ final class HlsSampleStreamWrapper implements Loader.Callback<Chunk>, + Loader.ReleaseCallback, SequenceableLoader, ExtractorOutput, UpstreamFormatChangedListener { + + /** + * A callback to be notified of events. + */ + public interface Callback extends SequenceableLoader.Callback<HlsSampleStreamWrapper> { + + /** + * Called when the wrapper has been prepared. + * + * <p>Note: This method will be called on a later handler loop than the one on which either + * {@link #prepareWithMasterPlaylistInfo} or {@link #continuePreparing} are invoked. + */ + void onPrepared(); + + /** + * Called to schedule a {@link #continueLoading(long)} call when the playlist referred by the + * given url changes. + */ + void onPlaylistRefreshRequired(Uri playlistUrl); + } + + private static final String TAG = "HlsSampleStreamWrapper"; + + public static final int SAMPLE_QUEUE_INDEX_PENDING = -1; + public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL = -2; + public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL = -3; + + private static final Set<Integer> MAPPABLE_TYPES = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList(C.TRACK_TYPE_AUDIO, C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_METADATA))); + + private final int trackType; + private final Callback callback; + private final HlsChunkSource chunkSource; + private final Allocator allocator; + @Nullable private final Format muxedAudioFormat; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final Loader loader; + private final EventDispatcher eventDispatcher; + private final @HlsMediaSource.MetadataType int metadataType; + private final HlsChunkSource.HlsChunkHolder nextChunkHolder; + private final ArrayList<HlsMediaChunk> mediaChunks; + private final List<HlsMediaChunk> readOnlyMediaChunks; + // Using runnables rather than in-line method references to avoid repeated allocations. + private final Runnable maybeFinishPrepareRunnable; + private final Runnable onTracksEndedRunnable; + private final Handler handler; + private final ArrayList<HlsSampleStream> hlsSampleStreams; + private final Map<String, DrmInitData> overridingDrmInitData; + + private FormatAdjustingSampleQueue[] sampleQueues; + private int[] sampleQueueTrackIds; + private Set<Integer> sampleQueueMappingDoneByType; + private SparseIntArray sampleQueueIndicesByType; + @MonotonicNonNull private TrackOutput emsgUnwrappingTrackOutput; + private int primarySampleQueueType; + private int primarySampleQueueIndex; + private boolean sampleQueuesBuilt; + private boolean prepared; + private int enabledTrackGroupCount; + @MonotonicNonNull private Format upstreamTrackFormat; + @Nullable private Format downstreamTrackFormat; + private boolean released; + + // Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details. + // Indexed by track (as exposed by this source). + @MonotonicNonNull private TrackGroupArray trackGroups; + @MonotonicNonNull private Set<TrackGroup> optionalTrackGroups; + // Indexed by track group. + private int @MonotonicNonNull [] trackGroupToSampleQueueIndex; + private int primaryTrackGroupIndex; + private boolean haveAudioVideoSampleQueues; + private boolean[] sampleQueuesEnabledStates; + private boolean[] sampleQueueIsAudioVideoFlags; + + private long lastSeekPositionUs; + private long pendingResetPositionUs; + private boolean pendingResetUpstreamFormats; + private boolean seenFirstTrackSelection; + private boolean loadingFinished; + + // Accessed only by the loading thread. + private boolean tracksEnded; + private long sampleOffsetUs; + @Nullable private DrmInitData drmInitData; + private int chunkUid; + + /** + * @param trackType The type of the track. One of the {@link C} {@code TRACK_TYPE_*} constants. + * @param callback A callback for the wrapper. + * @param chunkSource A {@link HlsChunkSource} from which chunks to load are obtained. + * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type + * (i.e. {@link DrmInitData#schemeType}). If the stream has {@link DrmInitData} and uses a + * protection scheme type for which overriding {@link DrmInitData} is provided, then the + * stream's {@link DrmInitData} will be overridden. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param positionUs The position from which to start loading media. + * @param muxedAudioFormat Optional muxed audio {@link Format} as defined by the master playlist. + * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession + * DrmSessions} with. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + */ + public HlsSampleStreamWrapper( + int trackType, + Callback callback, + HlsChunkSource chunkSource, + Map<String, DrmInitData> overridingDrmInitData, + Allocator allocator, + long positionUs, + @Nullable Format muxedAudioFormat, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + @HlsMediaSource.MetadataType int metadataType) { + this.trackType = trackType; + this.callback = callback; + this.chunkSource = chunkSource; + this.overridingDrmInitData = overridingDrmInitData; + this.allocator = allocator; + this.muxedAudioFormat = muxedAudioFormat; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.metadataType = metadataType; + loader = new Loader("Loader:HlsSampleStreamWrapper"); + nextChunkHolder = new HlsChunkSource.HlsChunkHolder(); + sampleQueueTrackIds = new int[0]; + sampleQueueMappingDoneByType = new HashSet<>(MAPPABLE_TYPES.size()); + sampleQueueIndicesByType = new SparseIntArray(MAPPABLE_TYPES.size()); + sampleQueues = new FormatAdjustingSampleQueue[0]; + sampleQueueIsAudioVideoFlags = new boolean[0]; + sampleQueuesEnabledStates = new boolean[0]; + mediaChunks = new ArrayList<>(); + readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks); + hlsSampleStreams = new ArrayList<>(); + // Suppressions are needed because `this` is not initialized here. + @SuppressWarnings("nullness:methodref.receiver.bound.invalid") + Runnable maybeFinishPrepareRunnable = this::maybeFinishPrepare; + this.maybeFinishPrepareRunnable = maybeFinishPrepareRunnable; + @SuppressWarnings("nullness:methodref.receiver.bound.invalid") + Runnable onTracksEndedRunnable = this::onTracksEnded; + this.onTracksEndedRunnable = onTracksEndedRunnable; + handler = new Handler(); + lastSeekPositionUs = positionUs; + pendingResetPositionUs = positionUs; + } + + public void continuePreparing() { + if (!prepared) { + continueLoading(lastSeekPositionUs); + } + } + + /** + * Prepares the sample stream wrapper with master playlist information. + * + * @param trackGroups The {@link TrackGroup TrackGroups} to expose through {@link + * #getTrackGroups()}. + * @param primaryTrackGroupIndex The index of the adaptive track group. + * @param optionalTrackGroupsIndices The indices of any {@code trackGroups} that should not + * trigger a failure if not found in the media playlist's segments. + */ + public void prepareWithMasterPlaylistInfo( + TrackGroup[] trackGroups, int primaryTrackGroupIndex, int... optionalTrackGroupsIndices) { + this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups); + optionalTrackGroups = new HashSet<>(); + for (int optionalTrackGroupIndex : optionalTrackGroupsIndices) { + optionalTrackGroups.add(this.trackGroups.get(optionalTrackGroupIndex)); + } + this.primaryTrackGroupIndex = primaryTrackGroupIndex; + handler.post(callback::onPrepared); + setIsPrepared(); + } + + public void maybeThrowPrepareError() throws IOException { + maybeThrowError(); + if (loadingFinished && !prepared) { + throw new ParserException("Loading finished before preparation is complete."); + } + } + + public TrackGroupArray getTrackGroups() { + assertIsPrepared(); + return trackGroups; + } + + public int getPrimaryTrackGroupIndex() { + return primaryTrackGroupIndex; + } + + public int bindSampleQueueToSampleStream(int trackGroupIndex) { + assertIsPrepared(); + Assertions.checkNotNull(trackGroupToSampleQueueIndex); + + int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; + if (sampleQueueIndex == C.INDEX_UNSET) { + return optionalTrackGroups.contains(trackGroups.get(trackGroupIndex)) + ? SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + : SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } + if (sampleQueuesEnabledStates[sampleQueueIndex]) { + // This sample queue is already bound to a different sample stream. + return SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } + sampleQueuesEnabledStates[sampleQueueIndex] = true; + return sampleQueueIndex; + } + + public void unbindSampleQueue(int trackGroupIndex) { + assertIsPrepared(); + Assertions.checkNotNull(trackGroupToSampleQueueIndex); + int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; + Assertions.checkState(sampleQueuesEnabledStates[sampleQueueIndex]); + sampleQueuesEnabledStates[sampleQueueIndex] = false; + } + + /** + * Called by the parent {@link HlsMediaPeriod} when a track selection occurs. + * + * @param selections The renderer track selections. + * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained + * for each selection. A {@code true} value indicates that the selection is unchanged, and + * that the caller does not require that the sample stream be recreated. + * @param streams The existing sample streams, which will be updated to reflect the provided + * selections. + * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that + * have been retained but with the requirement that the consuming renderer be reset. + * @param positionUs The current playback position in microseconds. + * @param forceReset If true then a reset is forced (i.e. a seek will be performed with in-buffer + * seeking disabled). + * @return Whether this wrapper requires the parent {@link HlsMediaPeriod} to perform a seek as + * part of the track selection. + */ + public boolean selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs, + boolean forceReset) { + assertIsPrepared(); + int oldEnabledTrackGroupCount = enabledTrackGroupCount; + // Deselect old tracks. + for (int i = 0; i < selections.length; i++) { + HlsSampleStream stream = (HlsSampleStream) streams[i]; + if (stream != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + enabledTrackGroupCount--; + stream.unbindSampleQueue(); + streams[i] = null; + } + } + // We'll always need to seek if we're being forced to reset, or if this is a first selection to + // a position other than the one we started preparing with, or if we're making a selection + // having previously disabled all tracks. + boolean seekRequired = + forceReset + || (seenFirstTrackSelection + ? oldEnabledTrackGroupCount == 0 + : positionUs != lastSeekPositionUs); + // Get the old (i.e. current before the loop below executes) primary track selection. The new + // primary selection will equal the old one unless it's changed in the loop. + TrackSelection oldPrimaryTrackSelection = chunkSource.getTrackSelection(); + TrackSelection primaryTrackSelection = oldPrimaryTrackSelection; + // Select new tracks. + for (int i = 0; i < selections.length; i++) { + TrackSelection selection = selections[i]; + if (selection == null) { + continue; + } + int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup()); + if (trackGroupIndex == primaryTrackGroupIndex) { + primaryTrackSelection = selection; + chunkSource.setTrackSelection(selection); + } + if (streams[i] == null) { + enabledTrackGroupCount++; + streams[i] = new HlsSampleStream(this, trackGroupIndex); + streamResetFlags[i] = true; + if (trackGroupToSampleQueueIndex != null) { + ((HlsSampleStream) streams[i]).bindSampleQueue(); + // If there's still a chance of avoiding a seek, try and seek within the sample queue. + if (!seekRequired) { + SampleQueue sampleQueue = sampleQueues[trackGroupToSampleQueueIndex[trackGroupIndex]]; + // A seek can be avoided if we're able to seek to the current playback position in + // the sample queue, or if we haven't read anything from the queue since the previous + // seek (this case is common for sparse tracks such as metadata tracks). In all other + // cases a seek is required. + seekRequired = + !sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true) + && sampleQueue.getReadIndex() != 0; + } + } + } + } + + if (enabledTrackGroupCount == 0) { + chunkSource.reset(); + downstreamTrackFormat = null; + pendingResetUpstreamFormats = true; + mediaChunks.clear(); + if (loader.isLoading()) { + if (sampleQueuesBuilt) { + // Discard as much as we can synchronously. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.discardToEnd(); + } + } + loader.cancelLoading(); + } else { + resetSampleQueues(); + } + } else { + if (!mediaChunks.isEmpty() + && !Util.areEqual(primaryTrackSelection, oldPrimaryTrackSelection)) { + // The primary track selection has changed and we have buffered media. The buffered media + // may need to be discarded. + boolean primarySampleQueueDirty = false; + if (!seenFirstTrackSelection) { + long bufferedDurationUs = positionUs < 0 ? -positionUs : 0; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + MediaChunkIterator[] mediaChunkIterators = + chunkSource.createMediaChunkIterators(lastMediaChunk, positionUs); + primaryTrackSelection.updateSelectedTrack( + positionUs, + bufferedDurationUs, + C.TIME_UNSET, + readOnlyMediaChunks, + mediaChunkIterators); + int chunkIndex = chunkSource.getTrackGroup().indexOf(lastMediaChunk.trackFormat); + if (primaryTrackSelection.getSelectedIndexInTrackGroup() != chunkIndex) { + // This is the first selection and the chunk loaded during preparation does not match + // the initially selected format. + primarySampleQueueDirty = true; + } + } else { + // The primary sample queue contains media buffered for the old primary track selection. + primarySampleQueueDirty = true; + } + if (primarySampleQueueDirty) { + forceReset = true; + seekRequired = true; + pendingResetUpstreamFormats = true; + } + } + if (seekRequired) { + seekToUs(positionUs, forceReset); + // We'll need to reset renderers consuming from all streams due to the seek. + for (int i = 0; i < streams.length; i++) { + if (streams[i] != null) { + streamResetFlags[i] = true; + } + } + } + } + + updateSampleStreams(streams); + seenFirstTrackSelection = true; + return seekRequired; + } + + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (!sampleQueuesBuilt || isPendingReset()) { + return; + } + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + sampleQueues[i].discardTo(positionUs, toKeyframe, sampleQueuesEnabledStates[i]); + } + } + + /** + * Attempts to seek to the specified position in microseconds. + * + * @param positionUs The seek position in microseconds. + * @param forceReset If true then a reset is forced (i.e. in-buffer seeking is disabled). + * @return Whether the wrapper was reset, meaning the wrapped sample queues were reset. If false, + * an in-buffer seek was performed. + */ + public boolean seekToUs(long positionUs, boolean forceReset) { + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return true; + } + + // If we're not forced to reset, try and seek within the buffer. + if (sampleQueuesBuilt && !forceReset && seekInsideBufferUs(positionUs)) { + return false; + } + + // We can't seek inside the buffer, and so need to reset. + pendingResetPositionUs = positionUs; + loadingFinished = false; + mediaChunks.clear(); + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + resetSampleQueues(); + } + return true; + } + + public void release() { + if (prepared) { + // Discard as much as we can synchronously. We only do this if we're prepared, since otherwise + // sampleQueues may still be being modified by the loading thread. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.preRelease(); + } + } + loader.release(this); + handler.removeCallbacksAndMessages(null); + released = true; + hlsSampleStreams.clear(); + } + + @Override + public void onLoaderReleased() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.release(); + } + } + + public void setIsTimestampMaster(boolean isTimestampMaster) { + chunkSource.setIsTimestampMaster(isTimestampMaster); + } + + public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + return chunkSource.onPlaylistError(playlistUrl, blacklistDurationMs); + } + + // SampleStream implementation. + + public boolean isReady(int sampleQueueIndex) { + return !isPendingReset() && sampleQueues[sampleQueueIndex].isReady(loadingFinished); + } + + public void maybeThrowError(int sampleQueueIndex) throws IOException { + maybeThrowError(); + sampleQueues[sampleQueueIndex].maybeThrowError(); + } + + public void maybeThrowError() throws IOException { + loader.maybeThrowError(); + chunkSource.maybeThrowError(); + } + + public int readData(int sampleQueueIndex, FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + + // TODO: Split into discard (in discardBuffer) and format change (here and in skipData) steps. + if (!mediaChunks.isEmpty()) { + int discardToMediaChunkIndex = 0; + while (discardToMediaChunkIndex < mediaChunks.size() - 1 + && finishedReadingChunk(mediaChunks.get(discardToMediaChunkIndex))) { + discardToMediaChunkIndex++; + } + Util.removeRange(mediaChunks, 0, discardToMediaChunkIndex); + HlsMediaChunk currentChunk = mediaChunks.get(0); + Format trackFormat = currentChunk.trackFormat; + if (!trackFormat.equals(downstreamTrackFormat)) { + eventDispatcher.downstreamFormatChanged(trackType, trackFormat, + currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + currentChunk.startTimeUs); + } + downstreamTrackFormat = trackFormat; + } + + int result = + sampleQueues[sampleQueueIndex].read( + formatHolder, buffer, requireFormat, loadingFinished, lastSeekPositionUs); + if (result == C.RESULT_FORMAT_READ) { + Format format = Assertions.checkNotNull(formatHolder.format); + if (sampleQueueIndex == primarySampleQueueIndex) { + // Fill in primary sample format with information from the track format. + int chunkUid = sampleQueues[sampleQueueIndex].peekSourceId(); + int chunkIndex = 0; + while (chunkIndex < mediaChunks.size() && mediaChunks.get(chunkIndex).uid != chunkUid) { + chunkIndex++; + } + Format trackFormat = + chunkIndex < mediaChunks.size() + ? mediaChunks.get(chunkIndex).trackFormat + : Assertions.checkNotNull(upstreamTrackFormat); + format = format.copyWithManifestFormatInfo(trackFormat); + } + formatHolder.format = format; + } + return result; + } + + public int skipData(int sampleQueueIndex, long positionUs) { + if (isPendingReset()) { + return 0; + } + + SampleQueue sampleQueue = sampleQueues[sampleQueueIndex]; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + return sampleQueue.advanceToEnd(); + } else { + return sampleQueue.advanceTo(positionUs); + } + } + + // SequenceableLoader implementation + + @Override + public long getBufferedPositionUs() { + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } else { + long bufferedPositionUs = lastSeekPositionUs; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + HlsMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk + : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; + if (lastCompletedMediaChunk != null) { + bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + } + if (sampleQueuesBuilt) { + for (SampleQueue sampleQueue : sampleQueues) { + bufferedPositionUs = + Math.max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs()); + } + } + return bufferedPositionUs; + } + } + + @Override + public long getNextLoadPositionUs() { + if (isPendingReset()) { + return pendingResetPositionUs; + } else { + return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs; + } + } + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + + List<HlsMediaChunk> chunkQueue; + long loadPositionUs; + if (isPendingReset()) { + chunkQueue = Collections.emptyList(); + loadPositionUs = pendingResetPositionUs; + } else { + chunkQueue = readOnlyMediaChunks; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + loadPositionUs = + lastMediaChunk.isLoadCompleted() + ? lastMediaChunk.endTimeUs + : Math.max(lastSeekPositionUs, lastMediaChunk.startTimeUs); + } + chunkSource.getNextChunk( + positionUs, + loadPositionUs, + chunkQueue, + /* allowEndOfStream= */ prepared || !chunkQueue.isEmpty(), + nextChunkHolder); + boolean endOfStream = nextChunkHolder.endOfStream; + Chunk loadable = nextChunkHolder.chunk; + Uri playlistUrlToLoad = nextChunkHolder.playlistUrl; + nextChunkHolder.clear(); + + if (endOfStream) { + pendingResetPositionUs = C.TIME_UNSET; + loadingFinished = true; + return true; + } + + if (loadable == null) { + if (playlistUrlToLoad != null) { + callback.onPlaylistRefreshRequired(playlistUrlToLoad); + } + return false; + } + + if (isMediaChunk(loadable)) { + pendingResetPositionUs = C.TIME_UNSET; + HlsMediaChunk mediaChunk = (HlsMediaChunk) loadable; + mediaChunk.init(this); + mediaChunks.add(mediaChunk); + upstreamTrackFormat = mediaChunk.trackFormat; + } + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); + eventDispatcher.loadStarted( + loadable.dataSpec, + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + chunkSource.onChunkLoadCompleted(loadable); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!prepared) { + continueLoading(lastSeekPositionUs); + } else { + callback.onContinueLoadingRequested(this); + } + } + + @Override + public void onLoadCanceled(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!released) { + resetSampleQueues(); + if (enabledTrackGroupCount > 0) { + callback.onContinueLoadingRequested(this); + } + } + } + + @Override + public LoadErrorAction onLoadError( + Chunk loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long bytesLoaded = loadable.bytesLoaded(); + boolean isMediaChunk = isMediaChunk(loadable); + boolean blacklistSucceeded = false; + LoadErrorAction loadErrorAction; + + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount); + if (blacklistDurationMs != C.TIME_UNSET) { + blacklistSucceeded = chunkSource.maybeBlacklistTrack(loadable, blacklistDurationMs); + } + + if (blacklistSucceeded) { + if (isMediaChunk && bytesLoaded == 0) { + HlsMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1); + Assertions.checkState(removed == loadable); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + } + loadErrorAction = Loader.DONT_RETRY; + } else /* did not blacklist */ { + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelayMs != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) + : Loader.DONT_RETRY_FATAL; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + /* wasCanceled= */ !loadErrorAction.isRetry()); + + if (blacklistSucceeded) { + if (!prepared) { + continueLoading(lastSeekPositionUs); + } else { + callback.onContinueLoadingRequested(this); + } + } + return loadErrorAction; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** + * Initializes the wrapper for loading a chunk. + * + * @param chunkUid The chunk's uid. + * @param shouldSpliceIn Whether the samples parsed from the chunk should be spliced into any + * samples already queued to the wrapper. + */ + public void init(int chunkUid, boolean shouldSpliceIn) { + this.chunkUid = chunkUid; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.sourceId(chunkUid); + } + if (shouldSpliceIn) { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.splice(); + } + } + } + + // ExtractorOutput implementation. Called by the loading thread. + + @Override + public TrackOutput track(int id, int type) { + @Nullable TrackOutput trackOutput = null; + if (MAPPABLE_TYPES.contains(type)) { + // Track types in MAPPABLE_TYPES are handled manually to ignore IDs. + trackOutput = getMappedTrackOutput(id, type); + } else /* non-mappable type track */ { + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueueTrackIds[i] == id) { + trackOutput = sampleQueues[i]; + break; + } + } + } + + if (trackOutput == null) { + if (tracksEnded) { + return createDummyTrackOutput(id, type); + } else { + // The relevant SampleQueue hasn't been constructed yet - so construct it. + trackOutput = createSampleQueue(id, type); + } + } + + if (type == C.TRACK_TYPE_METADATA) { + if (emsgUnwrappingTrackOutput == null) { + emsgUnwrappingTrackOutput = new EmsgUnwrappingTrackOutput(trackOutput, metadataType); + } + return emsgUnwrappingTrackOutput; + } + return trackOutput; + } + + /** + * Returns the {@link TrackOutput} for the provided {@code type} and {@code id}, or null if none + * has been created yet. + * + * <p>If a {@link SampleQueue} for {@code type} has been created and is mapped, but it has a + * different ID, then return a {@link DummyTrackOutput} that does nothing. + * + * <p>If a {@link SampleQueue} for {@code type} has been created but is not mapped, then map it to + * this {@code id} and return it. This situation can happen after a call to {@link + * #onNewExtractor}. + * + * @param id The ID of the track. + * @param type The type of the track, must be one of {@link #MAPPABLE_TYPES}. + * @return The the mapped {@link TrackOutput}, or null if it's not been created yet. + */ + @Nullable + private TrackOutput getMappedTrackOutput(int id, int type) { + Assertions.checkArgument(MAPPABLE_TYPES.contains(type)); + int sampleQueueIndex = sampleQueueIndicesByType.get(type, C.INDEX_UNSET); + if (sampleQueueIndex == C.INDEX_UNSET) { + return null; + } + + if (sampleQueueMappingDoneByType.add(type)) { + sampleQueueTrackIds[sampleQueueIndex] = id; + } + return sampleQueueTrackIds[sampleQueueIndex] == id + ? sampleQueues[sampleQueueIndex] + : createDummyTrackOutput(id, type); + } + + private SampleQueue createSampleQueue(int id, int type) { + int trackCount = sampleQueues.length; + + boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; + FormatAdjustingSampleQueue trackOutput = + new FormatAdjustingSampleQueue(allocator, drmSessionManager, overridingDrmInitData); + if (isAudioVideo) { + trackOutput.setDrmInitData(drmInitData); + } + trackOutput.setSampleOffsetUs(sampleOffsetUs); + trackOutput.sourceId(chunkUid); + trackOutput.setUpstreamFormatChangeListener(this); + sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1); + sampleQueueTrackIds[trackCount] = id; + sampleQueues = Util.nullSafeArrayAppend(sampleQueues, trackOutput); + sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1); + sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo; + haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount]; + sampleQueueMappingDoneByType.add(type); + sampleQueueIndicesByType.append(type, trackCount); + if (getTrackTypeScore(type) > getTrackTypeScore(primarySampleQueueType)) { + primarySampleQueueIndex = trackCount; + primarySampleQueueType = type; + } + sampleQueuesEnabledStates = Arrays.copyOf(sampleQueuesEnabledStates, trackCount + 1); + return trackOutput; + } + + @Override + public void endTracks() { + tracksEnded = true; + handler.post(onTracksEndedRunnable); + } + + @Override + public void seekMap(SeekMap seekMap) { + // Do nothing. + } + + // UpstreamFormatChangedListener implementation. Called by the loading thread. + + @Override + public void onUpstreamFormatChanged(Format format) { + handler.post(maybeFinishPrepareRunnable); + } + + // Called by the loading thread. + + /** Called when an {@link HlsMediaChunk} starts extracting media with a new {@link Extractor}. */ + public void onNewExtractor() { + sampleQueueMappingDoneByType.clear(); + } + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that + * are subsequently loaded by this wrapper. + * + * @param sampleOffsetUs The timestamp offset in microseconds. + */ + public void setSampleOffsetUs(long sampleOffsetUs) { + if (this.sampleOffsetUs != sampleOffsetUs) { + this.sampleOffsetUs = sampleOffsetUs; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setSampleOffsetUs(sampleOffsetUs); + } + } + } + + /** + * Sets default {@link DrmInitData} for samples that are subsequently loaded by this wrapper. + * + * <p>This method should be called prior to loading each {@link HlsMediaChunk}. The {@link + * DrmInitData} passed should be that of an EXT-X-KEY tag that applies to the chunk, or {@code + * null} otherwise. + * + * <p>The final {@link DrmInitData} for subsequently queued samples is determined as followed: + * + * <ol> + * <li>It is initially set to {@code drmInitData}, unless {@code drmInitData} is null in which + * case it's set to {@link Format#drmInitData} of the upstream {@link Format}. + * <li>If the initial {@link DrmInitData} is non-null and {@link #overridingDrmInitData} + * contains an entry whose key matches the {@link DrmInitData#schemeType}, then the sample's + * {@link DrmInitData} is overridden to be this entry's value. + * </ol> + * + * <p> + * + * @param drmInitData The default {@link DrmInitData} for samples that are subsequently queued. If + * non-null then it takes precedence over {@link Format#drmInitData} of the upstream {@link + * Format}, but will still be overridden by a matching override in {@link + * #overridingDrmInitData}. + */ + public void setDrmInitData(@Nullable DrmInitData drmInitData) { + if (!Util.areEqual(this.drmInitData, drmInitData)) { + this.drmInitData = drmInitData; + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueueIsAudioVideoFlags[i]) { + sampleQueues[i].setDrmInitData(drmInitData); + } + } + } + } + + // Internal methods. + + private void updateSampleStreams(@NullableType SampleStream[] streams) { + hlsSampleStreams.clear(); + for (SampleStream stream : streams) { + if (stream != null) { + hlsSampleStreams.add((HlsSampleStream) stream); + } + } + } + + private boolean finishedReadingChunk(HlsMediaChunk chunk) { + int chunkUid = chunk.uid; + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + if (sampleQueuesEnabledStates[i] && sampleQueues[i].peekSourceId() == chunkUid) { + return false; + } + } + return true; + } + + private void resetSampleQueues() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(pendingResetUpstreamFormats); + } + pendingResetUpstreamFormats = false; + } + + private void onTracksEnded() { + sampleQueuesBuilt = true; + maybeFinishPrepare(); + } + + private void maybeFinishPrepare() { + if (released || trackGroupToSampleQueueIndex != null || !sampleQueuesBuilt) { + return; + } + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue.getUpstreamFormat() == null) { + return; + } + } + if (trackGroups != null) { + // The track groups were created with master playlist information. They only need to be mapped + // to a sample queue. + mapSampleQueuesToMatchTrackGroups(); + } else { + // Tracks are created using media segment information. + buildTracksFromSampleStreams(); + setIsPrepared(); + callback.onPrepared(); + } + } + + @RequiresNonNull("trackGroups") + @EnsuresNonNull("trackGroupToSampleQueueIndex") + private void mapSampleQueuesToMatchTrackGroups() { + int trackGroupCount = trackGroups.length; + trackGroupToSampleQueueIndex = new int[trackGroupCount]; + Arrays.fill(trackGroupToSampleQueueIndex, C.INDEX_UNSET); + for (int i = 0; i < trackGroupCount; i++) { + for (int queueIndex = 0; queueIndex < sampleQueues.length; queueIndex++) { + SampleQueue sampleQueue = sampleQueues[queueIndex]; + if (formatsMatch(sampleQueue.getUpstreamFormat(), trackGroups.get(i).getFormat(0))) { + trackGroupToSampleQueueIndex[i] = queueIndex; + break; + } + } + } + for (HlsSampleStream sampleStream : hlsSampleStreams) { + sampleStream.bindSampleQueue(); + } + } + + /** + * Builds tracks that are exposed by this {@link HlsSampleStreamWrapper} instance, as well as + * internal data-structures required for operation. + * + * <p>Tracks in HLS are complicated. A HLS master playlist contains a number of "variants". Each + * variant stream typically contains muxed video, audio and (possibly) additional audio, metadata + * and caption tracks. We wish to allow the user to select between an adaptive track that spans + * all variants, as well as each individual variant. If multiple audio tracks are present within + * each variant then we wish to allow the user to select between those also. + * + * <p>To do this, tracks are constructed as follows. The {@link HlsChunkSource} exposes (N+1) + * tracks, where N is the number of variants defined in the HLS master playlist. These consist of + * one adaptive track defined to span all variants and a track for each individual variant. The + * adaptive track is initially selected. The extractor is then prepared to discover the tracks + * inside of each variant stream. The two sets of tracks are then combined by this method to + * create a third set, which is the set exposed by this {@link HlsSampleStreamWrapper}: + * + * <ul> + * <li>The extractor tracks are inspected to infer a "primary" track type. If a video track is + * present then it is always the primary type. If not, audio is the primary type if present. + * Else text is the primary type if present. Else there is no primary type. + * <li>If there is exactly one extractor track of the primary type, it's expanded into (N+1) + * exposed tracks, all of which correspond to the primary extractor track and each of which + * corresponds to a different chunk source track. Selecting one of these tracks has the + * effect of switching the selected track on the chunk source. + * <li>All other extractor tracks are exposed directly. Selecting one of these tracks has the + * effect of selecting an extractor track, leaving the selected track on the chunk source + * unchanged. + * </ul> + */ + @EnsuresNonNull({"trackGroups", "optionalTrackGroups", "trackGroupToSampleQueueIndex"}) + private void buildTracksFromSampleStreams() { + // Iterate through the extractor tracks to discover the "primary" track type, and the index + // of the single track of this type. + int primaryExtractorTrackType = C.TRACK_TYPE_NONE; + int primaryExtractorTrackIndex = C.INDEX_UNSET; + int extractorTrackCount = sampleQueues.length; + for (int i = 0; i < extractorTrackCount; i++) { + String sampleMimeType = sampleQueues[i].getUpstreamFormat().sampleMimeType; + int trackType; + if (MimeTypes.isVideo(sampleMimeType)) { + trackType = C.TRACK_TYPE_VIDEO; + } else if (MimeTypes.isAudio(sampleMimeType)) { + trackType = C.TRACK_TYPE_AUDIO; + } else if (MimeTypes.isText(sampleMimeType)) { + trackType = C.TRACK_TYPE_TEXT; + } else { + trackType = C.TRACK_TYPE_NONE; + } + if (getTrackTypeScore(trackType) > getTrackTypeScore(primaryExtractorTrackType)) { + primaryExtractorTrackType = trackType; + primaryExtractorTrackIndex = i; + } else if (trackType == primaryExtractorTrackType + && primaryExtractorTrackIndex != C.INDEX_UNSET) { + // We have multiple tracks of the primary type. We only want an index if there only exists a + // single track of the primary type, so unset the index again. + primaryExtractorTrackIndex = C.INDEX_UNSET; + } + } + + TrackGroup chunkSourceTrackGroup = chunkSource.getTrackGroup(); + int chunkSourceTrackCount = chunkSourceTrackGroup.length; + + // Instantiate the necessary internal data-structures. + primaryTrackGroupIndex = C.INDEX_UNSET; + trackGroupToSampleQueueIndex = new int[extractorTrackCount]; + for (int i = 0; i < extractorTrackCount; i++) { + trackGroupToSampleQueueIndex[i] = i; + } + + // Construct the set of exposed track groups. + TrackGroup[] trackGroups = new TrackGroup[extractorTrackCount]; + for (int i = 0; i < extractorTrackCount; i++) { + Format sampleFormat = sampleQueues[i].getUpstreamFormat(); + if (i == primaryExtractorTrackIndex) { + Format[] formats = new Format[chunkSourceTrackCount]; + if (chunkSourceTrackCount == 1) { + formats[0] = sampleFormat.copyWithManifestFormatInfo(chunkSourceTrackGroup.getFormat(0)); + } else { + for (int j = 0; j < chunkSourceTrackCount; j++) { + formats[j] = deriveFormat(chunkSourceTrackGroup.getFormat(j), sampleFormat, true); + } + } + trackGroups[i] = new TrackGroup(formats); + primaryTrackGroupIndex = i; + } else { + Format trackFormat = + primaryExtractorTrackType == C.TRACK_TYPE_VIDEO + && MimeTypes.isAudio(sampleFormat.sampleMimeType) + ? muxedAudioFormat + : null; + trackGroups[i] = new TrackGroup(deriveFormat(trackFormat, sampleFormat, false)); + } + } + this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups); + Assertions.checkState(optionalTrackGroups == null); + optionalTrackGroups = Collections.emptySet(); + } + + private TrackGroupArray createTrackGroupArrayWithDrmInfo(TrackGroup[] trackGroups) { + for (int i = 0; i < trackGroups.length; i++) { + TrackGroup trackGroup = trackGroups[i]; + Format[] exposedFormats = new Format[trackGroup.length]; + for (int j = 0; j < trackGroup.length; j++) { + Format format = trackGroup.getFormat(j); + if (format.drmInitData != null) { + format = + format.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(format.drmInitData)); + } + exposedFormats[j] = format; + } + trackGroups[i] = new TrackGroup(exposedFormats); + } + return new TrackGroupArray(trackGroups); + } + + private HlsMediaChunk getLastMediaChunk() { + return mediaChunks.get(mediaChunks.size() - 1); + } + + private boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + /** + * Attempts to seek to the specified position within the sample queues. + * + * @param positionUs The seek position in microseconds. + * @return Whether the in-buffer seek was successful. + */ + private boolean seekInsideBufferUs(long positionUs) { + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + SampleQueue sampleQueue = sampleQueues[i]; + boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false); + // If we have AV tracks then an in-queue seek is successful if the seek into every AV queue + // is successful. We ignore whether seeks within non-AV queues are successful in this case, as + // they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is + // successful only if the seek into every queue succeeds. + if (!seekInsideQueue && (sampleQueueIsAudioVideoFlags[i] || !haveAudioVideoSampleQueues)) { + return false; + } + } + return true; + } + + @RequiresNonNull({"trackGroups", "optionalTrackGroups"}) + private void setIsPrepared() { + prepared = true; + } + + @EnsuresNonNull({"trackGroups", "optionalTrackGroups"}) + private void assertIsPrepared() { + Assertions.checkState(prepared); + Assertions.checkNotNull(trackGroups); + Assertions.checkNotNull(optionalTrackGroups); + } + + /** + * Scores a track type. Where multiple tracks are muxed into a container, the track with the + * highest score is the primary track. + * + * @param trackType The track type. + * @return The score. + */ + private static int getTrackTypeScore(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return 3; + case C.TRACK_TYPE_AUDIO: + return 2; + case C.TRACK_TYPE_TEXT: + return 1; + default: + return 0; + } + } + + /** + * Derives a track sample format from the corresponding format in the master playlist, and a + * sample format that may have been obtained from a chunk belonging to a different track. + * + * @param playlistFormat The format information obtained from the master playlist. + * @param sampleFormat The format information obtained from the samples. + * @param propagateBitrate Whether the bitrate from the playlist format should be included in the + * derived format. + * @return The derived track format. + */ + private static Format deriveFormat( + @Nullable Format playlistFormat, Format sampleFormat, boolean propagateBitrate) { + if (playlistFormat == null) { + return sampleFormat; + } + int bitrate = propagateBitrate ? playlistFormat.bitrate : Format.NO_VALUE; + int channelCount = + playlistFormat.channelCount != Format.NO_VALUE + ? playlistFormat.channelCount + : sampleFormat.channelCount; + int sampleTrackType = MimeTypes.getTrackType(sampleFormat.sampleMimeType); + String codecs = Util.getCodecsOfType(playlistFormat.codecs, sampleTrackType); + String mimeType = MimeTypes.getMediaMimeType(codecs); + if (mimeType == null) { + mimeType = sampleFormat.sampleMimeType; + } + return sampleFormat.copyWithContainerInfo( + playlistFormat.id, + playlistFormat.label, + mimeType, + codecs, + playlistFormat.metadata, + bitrate, + playlistFormat.width, + playlistFormat.height, + channelCount, + playlistFormat.selectionFlags, + playlistFormat.language); + } + + private static boolean isMediaChunk(Chunk chunk) { + return chunk instanceof HlsMediaChunk; + } + + private static boolean formatsMatch(Format manifestFormat, Format sampleFormat) { + String manifestFormatMimeType = manifestFormat.sampleMimeType; + String sampleFormatMimeType = sampleFormat.sampleMimeType; + int manifestFormatTrackType = MimeTypes.getTrackType(manifestFormatMimeType); + if (manifestFormatTrackType != C.TRACK_TYPE_TEXT) { + return manifestFormatTrackType == MimeTypes.getTrackType(sampleFormatMimeType); + } else if (!Util.areEqual(manifestFormatMimeType, sampleFormatMimeType)) { + return false; + } + if (MimeTypes.APPLICATION_CEA608.equals(manifestFormatMimeType) + || MimeTypes.APPLICATION_CEA708.equals(manifestFormatMimeType)) { + return manifestFormat.accessibilityChannel == sampleFormat.accessibilityChannel; + } + return true; + } + + private static DummyTrackOutput createDummyTrackOutput(int id, int type) { + Log.w(TAG, "Unmapped track with id " + id + " of type " + type); + return new DummyTrackOutput(); + } + + private static final class FormatAdjustingSampleQueue extends SampleQueue { + + private final Map<String, DrmInitData> overridingDrmInitData; + @Nullable private DrmInitData drmInitData; + + public FormatAdjustingSampleQueue( + Allocator allocator, + DrmSessionManager<?> drmSessionManager, + Map<String, DrmInitData> overridingDrmInitData) { + super(allocator, drmSessionManager); + this.overridingDrmInitData = overridingDrmInitData; + } + + public void setDrmInitData(@Nullable DrmInitData drmInitData) { + this.drmInitData = drmInitData; + invalidateUpstreamFormatAdjustment(); + } + + @Override + public Format getAdjustedUpstreamFormat(Format format) { + @Nullable + DrmInitData drmInitData = this.drmInitData != null ? this.drmInitData : format.drmInitData; + if (drmInitData != null) { + @Nullable + DrmInitData overridingDrmInitData = this.overridingDrmInitData.get(drmInitData.schemeType); + if (overridingDrmInitData != null) { + drmInitData = overridingDrmInitData; + } + } + return super.getAdjustedUpstreamFormat( + format.copyWithAdjustments(drmInitData, getAdjustedMetadata(format.metadata))); + } + + /** + * Strips the private timestamp frame from metadata, if present. See: + * https://github.com/google/ExoPlayer/issues/5063 + */ + @Nullable + private Metadata getAdjustedMetadata(@Nullable Metadata metadata) { + if (metadata == null) { + return null; + } + int length = metadata.length(); + int transportStreamTimestampMetadataIndex = C.INDEX_UNSET; + for (int i = 0; i < length; i++) { + Metadata.Entry metadataEntry = metadata.get(i); + if (metadataEntry instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) metadataEntry; + if (HlsMediaChunk.PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { + transportStreamTimestampMetadataIndex = i; + break; + } + } + } + if (transportStreamTimestampMetadataIndex == C.INDEX_UNSET) { + return metadata; + } + if (length == 1) { + return null; + } + Metadata.Entry[] newMetadataEntries = new Metadata.Entry[length - 1]; + for (int i = 0; i < length; i++) { + if (i != transportStreamTimestampMetadataIndex) { + int newIndex = i < transportStreamTimestampMetadataIndex ? i : i - 1; + newMetadataEntries[newIndex] = metadata.get(i); + } + } + return new Metadata(newMetadataEntries); + } + } + + private static class EmsgUnwrappingTrackOutput implements TrackOutput { + + private static final String TAG = "EmsgUnwrappingTrackOutput"; + + // TODO(ibaker): Create a Formats util class with common constants like this. + private static final Format ID3_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_ID3, Format.OFFSET_SAMPLE_RELATIVE); + private static final Format EMSG_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_EMSG, Format.OFFSET_SAMPLE_RELATIVE); + + private final EventMessageDecoder emsgDecoder; + private final TrackOutput delegate; + private final Format delegateFormat; + @MonotonicNonNull private Format format; + + private byte[] buffer; + private int bufferPosition; + + public EmsgUnwrappingTrackOutput( + TrackOutput delegate, @HlsMediaSource.MetadataType int metadataType) { + this.emsgDecoder = new EventMessageDecoder(); + this.delegate = delegate; + switch (metadataType) { + case HlsMediaSource.METADATA_TYPE_ID3: + delegateFormat = ID3_FORMAT; + break; + case HlsMediaSource.METADATA_TYPE_EMSG: + delegateFormat = EMSG_FORMAT; + break; + default: + throw new IllegalArgumentException("Unknown metadataType: " + metadataType); + } + + this.buffer = new byte[0]; + this.bufferPosition = 0; + } + + @Override + public void format(Format format) { + this.format = format; + delegate.format(delegateFormat); + } + + @Override + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + ensureBufferCapacity(bufferPosition + length); + int numBytesRead = input.read(buffer, bufferPosition, length); + if (numBytesRead == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } else { + throw new EOFException(); + } + } + bufferPosition += numBytesRead; + return numBytesRead; + } + + @Override + public void sampleData(ParsableByteArray buffer, int length) { + ensureBufferCapacity(bufferPosition + length); + buffer.readBytes(this.buffer, bufferPosition, length); + bufferPosition += length; + } + + @Override + public void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + Assertions.checkNotNull(format); + ParsableByteArray sample = getSampleAndTrimBuffer(size, offset); + ParsableByteArray sampleForDelegate; + if (Util.areEqual(format.sampleMimeType, delegateFormat.sampleMimeType)) { + // Incoming format matches delegate track's format, so pass straight through. + sampleForDelegate = sample; + } else if (MimeTypes.APPLICATION_EMSG.equals(format.sampleMimeType)) { + // Incoming sample is EMSG, and delegate track is not expecting EMSG, so try unwrapping. + EventMessage emsg = emsgDecoder.decode(sample); + if (!emsgContainsExpectedWrappedFormat(emsg)) { + Log.w( + TAG, + String.format( + "Ignoring EMSG. Expected it to contain wrapped %s but actual wrapped format: %s", + delegateFormat.sampleMimeType, emsg.getWrappedMetadataFormat())); + return; + } + sampleForDelegate = + new ParsableByteArray(Assertions.checkNotNull(emsg.getWrappedMetadataBytes())); + } else { + Log.w(TAG, "Ignoring sample for unsupported format: " + format.sampleMimeType); + return; + } + + int sampleSize = sampleForDelegate.bytesLeft(); + + delegate.sampleData(sampleForDelegate, sampleSize); + delegate.sampleMetadata(timeUs, flags, sampleSize, offset, cryptoData); + } + + private boolean emsgContainsExpectedWrappedFormat(EventMessage emsg) { + @Nullable Format wrappedMetadataFormat = emsg.getWrappedMetadataFormat(); + return wrappedMetadataFormat != null + && Util.areEqual(delegateFormat.sampleMimeType, wrappedMetadataFormat.sampleMimeType); + } + + private void ensureBufferCapacity(int requiredLength) { + if (buffer.length < requiredLength) { + buffer = Arrays.copyOf(buffer, requiredLength + requiredLength / 2); + } + } + + /** + * Removes a complete sample from the {@link #buffer} field & reshuffles the tail data skipped + * by {@code offset} to the head of the array. + * + * @param size see {@code size} param of {@link #sampleMetadata}. + * @param offset see {@code offset} param of {@link #sampleMetadata}. + * @return A {@link ParsableByteArray} containing the sample removed from {@link #buffer}. + */ + private ParsableByteArray getSampleAndTrimBuffer(int size, int offset) { + int sampleEnd = bufferPosition - offset; + int sampleStart = sampleEnd - size; + + byte[] sampleBytes = Arrays.copyOfRange(buffer, sampleStart, sampleEnd); + ParsableByteArray sample = new ParsableByteArray(sampleBytes); + + System.arraycopy(buffer, sampleEnd, buffer, 0, offset); + bufferPosition = offset; + return sample; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java new file mode 100644 index 0000000000..681fe57240 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Holds metadata associated to an HLS media track. */ +public final class HlsTrackMetadataEntry implements Metadata.Entry { + + /** Holds attributes defined in an EXT-X-STREAM-INF tag. */ + public static final class VariantInfo implements Parcelable { + + /** The bitrate as declared by the EXT-X-STREAM-INF tag. */ + public final long bitrate; + + /** + * The VIDEO value as defined in the EXT-X-STREAM-INF tag, or null if the VIDEO attribute is not + * present. + */ + @Nullable public final String videoGroupId; + + /** + * The AUDIO value as defined in the EXT-X-STREAM-INF tag, or null if the AUDIO attribute is not + * present. + */ + @Nullable public final String audioGroupId; + + /** + * The SUBTITLES value as defined in the EXT-X-STREAM-INF tag, or null if the SUBTITLES + * attribute is not present. + */ + @Nullable public final String subtitleGroupId; + + /** + * The CLOSED-CAPTIONS value as defined in the EXT-X-STREAM-INF tag, or null if the + * CLOSED-CAPTIONS attribute is not present. + */ + @Nullable public final String captionGroupId; + + /** + * Creates an instance. + * + * @param bitrate See {@link #bitrate}. + * @param videoGroupId See {@link #videoGroupId}. + * @param audioGroupId See {@link #audioGroupId}. + * @param subtitleGroupId See {@link #subtitleGroupId}. + * @param captionGroupId See {@link #captionGroupId}. + */ + public VariantInfo( + long bitrate, + @Nullable String videoGroupId, + @Nullable String audioGroupId, + @Nullable String subtitleGroupId, + @Nullable String captionGroupId) { + this.bitrate = bitrate; + this.videoGroupId = videoGroupId; + this.audioGroupId = audioGroupId; + this.subtitleGroupId = subtitleGroupId; + this.captionGroupId = captionGroupId; + } + + /* package */ VariantInfo(Parcel in) { + bitrate = in.readLong(); + videoGroupId = in.readString(); + audioGroupId = in.readString(); + subtitleGroupId = in.readString(); + captionGroupId = in.readString(); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + VariantInfo that = (VariantInfo) other; + return bitrate == that.bitrate + && TextUtils.equals(videoGroupId, that.videoGroupId) + && TextUtils.equals(audioGroupId, that.audioGroupId) + && TextUtils.equals(subtitleGroupId, that.subtitleGroupId) + && TextUtils.equals(captionGroupId, that.captionGroupId); + } + + @Override + public int hashCode() { + int result = (int) (bitrate ^ (bitrate >>> 32)); + result = 31 * result + (videoGroupId != null ? videoGroupId.hashCode() : 0); + result = 31 * result + (audioGroupId != null ? audioGroupId.hashCode() : 0); + result = 31 * result + (subtitleGroupId != null ? subtitleGroupId.hashCode() : 0); + result = 31 * result + (captionGroupId != null ? captionGroupId.hashCode() : 0); + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(bitrate); + dest.writeString(videoGroupId); + dest.writeString(audioGroupId); + dest.writeString(subtitleGroupId); + dest.writeString(captionGroupId); + } + + public static final Parcelable.Creator<VariantInfo> CREATOR = + new Parcelable.Creator<VariantInfo>() { + @Override + public VariantInfo createFromParcel(Parcel in) { + return new VariantInfo(in); + } + + @Override + public VariantInfo[] newArray(int size) { + return new VariantInfo[size]; + } + }; + } + + /** + * The GROUP-ID value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the + * track is not derived from an EXT-X-MEDIA TAG. + */ + @Nullable public final String groupId; + /** + * The NAME value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the + * track is not derived from an EXT-X-MEDIA TAG. + */ + @Nullable public final String name; + /** + * The EXT-X-STREAM-INF tags attributes associated with this track. This field is non-applicable + * (and therefore empty) if this track is derived from an EXT-X-MEDIA tag. + */ + public final List<VariantInfo> variantInfos; + + /** + * Creates an instance. + * + * @param groupId See {@link #groupId}. + * @param name See {@link #name}. + * @param variantInfos See {@link #variantInfos}. + */ + public HlsTrackMetadataEntry( + @Nullable String groupId, @Nullable String name, List<VariantInfo> variantInfos) { + this.groupId = groupId; + this.name = name; + this.variantInfos = Collections.unmodifiableList(new ArrayList<>(variantInfos)); + } + + /* package */ HlsTrackMetadataEntry(Parcel in) { + groupId = in.readString(); + name = in.readString(); + int variantInfoSize = in.readInt(); + ArrayList<VariantInfo> variantInfos = new ArrayList<>(variantInfoSize); + for (int i = 0; i < variantInfoSize; i++) { + variantInfos.add(in.readParcelable(VariantInfo.class.getClassLoader())); + } + this.variantInfos = Collections.unmodifiableList(variantInfos); + } + + @Override + public String toString() { + return "HlsTrackMetadataEntry" + (groupId != null ? (" [" + groupId + ", " + name + "]") : ""); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + HlsTrackMetadataEntry that = (HlsTrackMetadataEntry) other; + return TextUtils.equals(groupId, that.groupId) + && TextUtils.equals(name, that.name) + && variantInfos.equals(that.variantInfos); + } + + @Override + public int hashCode() { + int result = groupId != null ? groupId.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + variantInfos.hashCode(); + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(groupId); + dest.writeString(name); + int variantInfosSize = variantInfos.size(); + dest.writeInt(variantInfosSize); + for (int i = 0; i < variantInfosSize; i++) { + dest.writeParcelable(variantInfos.get(i), /* parcelableFlags= */ 0); + } + } + + public static final Parcelable.Creator<HlsTrackMetadataEntry> CREATOR = + new Parcelable.Creator<HlsTrackMetadataEntry>() { + @Override + public HlsTrackMetadataEntry createFromParcel(Parcel in) { + return new HlsTrackMetadataEntry(in); + } + + @Override + public HlsTrackMetadataEntry[] newArray(int size) { + return new HlsTrackMetadataEntry[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java new file mode 100644 index 0000000000..a67a92b4b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import java.io.IOException; + +/** Thrown when it is not possible to map a {@link TrackGroup} to a {@link SampleQueue}. */ +public final class SampleQueueMappingException extends IOException { + + /** @param mimeType The mime type of the track group whose mapping failed. */ + public SampleQueueMappingException(@Nullable String mimeType) { + super("Unable to bind a sample queue to TrackGroup with mime type " + mimeType + "."); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java new file mode 100644 index 0000000000..e2a652d05c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.util.SparseArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Provides {@link TimestampAdjuster} instances for use during HLS playbacks. + */ +public final class TimestampAdjusterProvider { + + // TODO: Prevent this array from growing indefinitely large by removing adjusters that are no + // longer required. + private final SparseArray<TimestampAdjuster> timestampAdjusters; + + public TimestampAdjusterProvider() { + timestampAdjusters = new SparseArray<>(); + } + + /** + * Returns a {@link TimestampAdjuster} suitable for adjusting the pts timestamps contained in + * a chunk with a given discontinuity sequence. + * + * @param discontinuitySequence The chunk's discontinuity sequence. + * @return A {@link TimestampAdjuster}. + */ + public TimestampAdjuster getAdjuster(int discontinuitySequence) { + TimestampAdjuster adjuster = timestampAdjusters.get(discontinuitySequence); + if (adjuster == null) { + adjuster = new TimestampAdjuster(TimestampAdjuster.DO_NOT_OFFSET); + timestampAdjusters.put(discontinuitySequence, adjuster); + } + return adjuster; + } + + /** + * Resets the provider. + */ + public void reset() { + timestampAdjusters.clear(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java new file mode 100644 index 0000000000..1d5e669a03 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.WebvttParserUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * A special purpose extractor for WebVTT content in HLS. + * + * <p>This extractor passes through non-empty WebVTT files untouched, however derives the correct + * sample timestamp for each by sniffing the X-TIMESTAMP-MAP header along with the start timestamp + * of the first cue header. Empty WebVTT files are not passed through, since it's not possible to + * derive a sample timestamp in this case. + */ +public final class WebvttExtractor implements Extractor { + + private static final Pattern LOCAL_TIMESTAMP = Pattern.compile("LOCAL:([^,]+)"); + private static final Pattern MEDIA_TIMESTAMP = Pattern.compile("MPEGTS:(-?\\d+)"); + private static final int HEADER_MIN_LENGTH = 6 /* "WEBVTT" */; + private static final int HEADER_MAX_LENGTH = 3 /* optional Byte Order Mark */ + HEADER_MIN_LENGTH; + + @Nullable private final String language; + private final TimestampAdjuster timestampAdjuster; + private final ParsableByteArray sampleDataWrapper; + + private @MonotonicNonNull ExtractorOutput output; + + private byte[] sampleData; + private int sampleSize; + + public WebvttExtractor(@Nullable String language, TimestampAdjuster timestampAdjuster) { + this.language = language; + this.timestampAdjuster = timestampAdjuster; + this.sampleDataWrapper = new ParsableByteArray(); + sampleData = new byte[1024]; + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Check whether there is a header without BOM. + input.peekFully( + sampleData, /* offset= */ 0, /* length= */ HEADER_MIN_LENGTH, /* allowEndOfInput= */ false); + sampleDataWrapper.reset(sampleData, HEADER_MIN_LENGTH); + if (WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper)) { + return true; + } + // The header did not match, try including the BOM. + input.peekFully( + sampleData, + /* offset= */ HEADER_MIN_LENGTH, + HEADER_MAX_LENGTH - HEADER_MIN_LENGTH, + /* allowEndOfInput= */ false); + sampleDataWrapper.reset(sampleData, HEADER_MAX_LENGTH); + return WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper); + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + } + + @Override + public void seek(long position, long timeUs) { + // This extractor is only used for the HLS use case, which should not call this method. + throw new IllegalStateException(); + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + // output == null suggests init() hasn't been called + Assertions.checkNotNull(output); + int currentFileSize = (int) input.getLength(); + + // Increase the size of sampleData if necessary. + if (sampleSize == sampleData.length) { + sampleData = Arrays.copyOf(sampleData, + (currentFileSize != C.LENGTH_UNSET ? currentFileSize : sampleData.length) * 3 / 2); + } + + // Consume to the input. + int bytesRead = input.read(sampleData, sampleSize, sampleData.length - sampleSize); + if (bytesRead != C.RESULT_END_OF_INPUT) { + sampleSize += bytesRead; + if (currentFileSize == C.LENGTH_UNSET || sampleSize != currentFileSize) { + return Extractor.RESULT_CONTINUE; + } + } + + // We've reached the end of the input, which corresponds to the end of the current file. + processSample(); + return Extractor.RESULT_END_OF_INPUT; + } + + @RequiresNonNull("output") + private void processSample() throws ParserException { + ParsableByteArray webvttData = new ParsableByteArray(sampleData); + + // Validate the first line of the header. + WebvttParserUtil.validateWebvttHeaderLine(webvttData); + + // Defaults to use if the header doesn't contain an X-TIMESTAMP-MAP header. + long vttTimestampUs = 0; + long tsTimestampUs = 0; + + // Parse the remainder of the header looking for X-TIMESTAMP-MAP. + for (String line = webvttData.readLine(); + !TextUtils.isEmpty(line); + line = webvttData.readLine()) { + if (line.startsWith("X-TIMESTAMP-MAP")) { + Matcher localTimestampMatcher = LOCAL_TIMESTAMP.matcher(line); + if (!localTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain local timestamp: " + line); + } + Matcher mediaTimestampMatcher = MEDIA_TIMESTAMP.matcher(line); + if (!mediaTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain media timestamp: " + line); + } + vttTimestampUs = WebvttParserUtil.parseTimestampUs(localTimestampMatcher.group(1)); + tsTimestampUs = TimestampAdjuster.ptsToUs(Long.parseLong(mediaTimestampMatcher.group(1))); + } + } + + // Find the first cue header and parse the start time. + Matcher cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(webvttData); + if (cueHeaderMatcher == null) { + // No cues found. Don't output a sample, but still output a corresponding track. + buildTrackOutput(0); + return; + } + + long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1)); + long sampleTimeUs = timestampAdjuster.adjustTsTimestamp( + TimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); + long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs; + // Output the track. + TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs); + // Output the sample. + sampleDataWrapper.reset(sampleData, sampleSize); + trackOutput.sampleData(sampleDataWrapper, sampleSize); + trackOutput.sampleMetadata(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + } + + @RequiresNonNull("output") + private TrackOutput buildTrackOutput(long subsampleOffsetUs) { + TrackOutput trackOutput = output.track(0, C.TRACK_TYPE_TEXT); + trackOutput.format(Format.createTextSampleFormat(null, MimeTypes.TEXT_VTT, null, + Format.NO_VALUE, 0, language, null, subsampleOffsetUs)); + output.endTracks(); + return trackOutput; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java new file mode 100644 index 0000000000..636100a8a9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.DownloaderConstructorHelper; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.SegmentDownloader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** + * A downloader for HLS streams. + * + * <p>Example usage: + * + * <pre>{@code + * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider); + * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null); + * DownloaderConstructorHelper constructorHelper = + * new DownloaderConstructorHelper(cache, factory); + * // Create a downloader for the first variant in a master playlist. + * HlsDownloader hlsDownloader = + * new HlsDownloader( + * playlistUri, + * Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)), + * constructorHelper); + * // Perform the download. + * hlsDownloader.download(progressListener); + * // Access downloaded data using CacheDataSource + * CacheDataSource cacheDataSource = + * new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE); + * }</pre> + */ +public final class HlsDownloader extends SegmentDownloader<HlsPlaylist> { + + /** + * @param playlistUri The {@link Uri} of the playlist to be downloaded. + * @param streamKeys Keys defining which renditions in the playlist should be selected for + * download. If empty, all renditions are downloaded. + * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + */ + public HlsDownloader( + Uri playlistUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) { + super(playlistUri, streamKeys, constructorHelper); + } + + @Override + protected HlsPlaylist getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { + return loadManifest(dataSource, dataSpec); + } + + @Override + protected List<Segment> getSegments( + DataSource dataSource, HlsPlaylist playlist, boolean allowIncompleteList) throws IOException { + ArrayList<DataSpec> mediaPlaylistDataSpecs = new ArrayList<>(); + if (playlist instanceof HlsMasterPlaylist) { + HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; + addMediaPlaylistDataSpecs(masterPlaylist.mediaPlaylistUrls, mediaPlaylistDataSpecs); + } else { + mediaPlaylistDataSpecs.add( + SegmentDownloader.getCompressibleDataSpec(Uri.parse(playlist.baseUri))); + } + + ArrayList<Segment> segments = new ArrayList<>(); + HashSet<Uri> seenEncryptionKeyUris = new HashSet<>(); + for (DataSpec mediaPlaylistDataSpec : mediaPlaylistDataSpecs) { + segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); + HlsMediaPlaylist mediaPlaylist; + try { + mediaPlaylist = (HlsMediaPlaylist) loadManifest(dataSource, mediaPlaylistDataSpec); + } catch (IOException e) { + if (!allowIncompleteList) { + throw e; + } + // Generating an incomplete segment list is allowed. Advance to the next media playlist. + continue; + } + HlsMediaPlaylist.Segment lastInitSegment = null; + List<HlsMediaPlaylist.Segment> hlsSegments = mediaPlaylist.segments; + for (int i = 0; i < hlsSegments.size(); i++) { + HlsMediaPlaylist.Segment segment = hlsSegments.get(i); + HlsMediaPlaylist.Segment initSegment = segment.initializationSegment; + if (initSegment != null && initSegment != lastInitSegment) { + lastInitSegment = initSegment; + addSegment(mediaPlaylist, initSegment, seenEncryptionKeyUris, segments); + } + addSegment(mediaPlaylist, segment, seenEncryptionKeyUris, segments); + } + } + return segments; + } + + private void addMediaPlaylistDataSpecs(List<Uri> mediaPlaylistUrls, List<DataSpec> out) { + for (int i = 0; i < mediaPlaylistUrls.size(); i++) { + out.add(SegmentDownloader.getCompressibleDataSpec(mediaPlaylistUrls.get(i))); + } + } + + private static HlsPlaylist loadManifest(DataSource dataSource, DataSpec dataSpec) + throws IOException { + return ParsingLoadable.load( + dataSource, new HlsPlaylistParser(), dataSpec, C.DATA_TYPE_MANIFEST); + } + + private void addSegment( + HlsMediaPlaylist mediaPlaylist, + HlsMediaPlaylist.Segment segment, + HashSet<Uri> seenEncryptionKeyUris, + ArrayList<Segment> out) { + String baseUri = mediaPlaylist.baseUri; + long startTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs; + if (segment.fullSegmentEncryptionKeyUri != null) { + Uri keyUri = UriUtil.resolveToUri(baseUri, segment.fullSegmentEncryptionKeyUri); + if (seenEncryptionKeyUris.add(keyUri)) { + out.add(new Segment(startTimeUs, SegmentDownloader.getCompressibleDataSpec(keyUri))); + } + } + Uri segmentUri = UriUtil.resolveToUri(baseUri, segment.url); + DataSpec dataSpec = + new DataSpec(segmentUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null); + out.add(new Segment(startTimeUs, dataSpec)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java new file mode 100644 index 0000000000..669bd44c89 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java new file mode 100644 index 0000000000..89882bb596 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..394a97a56a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Default implementation for {@link HlsPlaylistParserFactory}. */ +public final class DefaultHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new HlsPlaylistParser(); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new HlsPlaylistParser(masterPlaylist); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java new file mode 100644 index 0000000000..b7f6a06975 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -0,0 +1,678 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** Default implementation for {@link HlsPlaylistTracker}. */ +public final class DefaultHlsPlaylistTracker + implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> { + + /** Factory for {@link DefaultHlsPlaylistTracker} instances. */ + public static final Factory FACTORY = DefaultHlsPlaylistTracker::new; + + /** + * Default coefficient applied on the target duration of a playlist to determine the amount of + * time after which an unchanging playlist is considered stuck. + */ + public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5; + + private final HlsDataSourceFactory dataSourceFactory; + private final HlsPlaylistParserFactory playlistParserFactory; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final HashMap<Uri, MediaPlaylistBundle> playlistBundles; + private final List<PlaylistEventListener> listeners; + private final double playlistStuckTargetDurationCoefficient; + + @Nullable private ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser; + @Nullable private EventDispatcher eventDispatcher; + @Nullable private Loader initialPlaylistLoader; + @Nullable private Handler playlistRefreshHandler; + @Nullable private PrimaryPlaylistListener primaryPlaylistListener; + @Nullable private HlsMasterPlaylist masterPlaylist; + @Nullable private Uri primaryMediaPlaylistUrl; + @Nullable private HlsMediaPlaylist primaryMediaPlaylistSnapshot; + private boolean isLive; + private long initialStartTimeUs; + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + */ + public DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory) { + this( + dataSourceFactory, + loadErrorHandlingPolicy, + playlistParserFactory, + DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT); + } + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of + * media playlists in order to determine that a non-changing playlist is stuck. Once a + * playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link + * #maybeThrowPlaylistRefreshError(Uri)}. + */ + public DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory, + double playlistStuckTargetDurationCoefficient) { + this.dataSourceFactory = dataSourceFactory; + this.playlistParserFactory = playlistParserFactory; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient; + listeners = new ArrayList<>(); + playlistBundles = new HashMap<>(); + initialStartTimeUs = C.TIME_UNSET; + } + + // HlsPlaylistTracker implementation. + + @Override + public void start( + Uri initialPlaylistUri, + EventDispatcher eventDispatcher, + PrimaryPlaylistListener primaryPlaylistListener) { + this.playlistRefreshHandler = new Handler(); + this.eventDispatcher = eventDispatcher; + this.primaryPlaylistListener = primaryPlaylistListener; + ParsingLoadable<HlsPlaylist> masterPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + initialPlaylistUri, + C.DATA_TYPE_MANIFEST, + playlistParserFactory.createPlaylistParser()); + Assertions.checkState(initialPlaylistLoader == null); + initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MasterPlaylist"); + long elapsedRealtime = + initialPlaylistLoader.startLoading( + masterPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type)); + eventDispatcher.loadStarted( + masterPlaylistLoadable.dataSpec, + masterPlaylistLoadable.type, + elapsedRealtime); + } + + @Override + public void stop() { + primaryMediaPlaylistUrl = null; + primaryMediaPlaylistSnapshot = null; + masterPlaylist = null; + initialStartTimeUs = C.TIME_UNSET; + initialPlaylistLoader.release(); + initialPlaylistLoader = null; + for (MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistRefreshHandler = null; + playlistBundles.clear(); + } + + @Override + public void addListener(PlaylistEventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(PlaylistEventListener listener) { + listeners.remove(listener); + } + + @Override + @Nullable + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + @Override + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) { + HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot(); + if (snapshot != null && isForPlayback) { + maybeSetPrimaryUrl(url); + } + return snapshot; + } + + @Override + public long getInitialStartTimeUs() { + return initialStartTimeUs; + } + + @Override + public boolean isSnapshotValid(Uri url) { + return playlistBundles.get(url).isSnapshotValid(); + } + + @Override + public void maybeThrowPrimaryPlaylistRefreshError() throws IOException { + if (initialPlaylistLoader != null) { + initialPlaylistLoader.maybeThrowError(); + } + if (primaryMediaPlaylistUrl != null) { + maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl); + } + } + + @Override + public void maybeThrowPlaylistRefreshError(Uri url) throws IOException { + playlistBundles.get(url).maybeThrowPlaylistRefreshError(); + } + + @Override + public void refreshPlaylist(Uri url) { + playlistBundles.get(url).loadPlaylist(); + } + + @Override + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + HlsMasterPlaylist masterPlaylist; + boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri); + } else /* result instanceof HlsMasterPlaylist */ { + masterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = masterPlaylist; + mediaPlaylistParser = playlistParserFactory.createPlaylistParser(masterPlaylist); + primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; + createBundles(masterPlaylist.mediaPlaylistUrls); + MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + } else { + primaryBundle.loadPlaylist(); + } + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean isFatal = retryDelayMs == C.TIME_UNSET; + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + isFatal); + return isFatal + ? Loader.DONT_RETRY_FATAL + : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); + } + + // Internal methods. + + private boolean maybeSelectNewPrimaryUrl() { + List<Variant> variants = masterPlaylist.variants; + int variantsSize = variants.size(); + long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < variantsSize; i++) { + MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url); + if (currentTimeMs > bundle.blacklistUntilMs) { + primaryMediaPlaylistUrl = bundle.playlistUrl; + bundle.loadPlaylist(); + return true; + } + } + return false; + } + + private void maybeSetPrimaryUrl(Uri url) { + if (url.equals(primaryMediaPlaylistUrl) + || !isVariantUrl(url) + || (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.hasEndTag)) { + // Ignore if the primary media playlist URL is unchanged, if the media playlist is not + // referenced directly by a variant, or it the last primary snapshot contains an end tag. + return; + } + primaryMediaPlaylistUrl = url; + playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist(); + } + + /** Returns whether any of the variants in the master playlist have the specified playlist URL. */ + private boolean isVariantUrl(Uri playlistUrl) { + List<Variant> variants = masterPlaylist.variants; + for (int i = 0; i < variants.size(); i++) { + if (playlistUrl.equals(variants.get(i).url)) { + return true; + } + } + return false; + } + + private void createBundles(List<Uri> urls) { + int listSize = urls.size(); + for (int i = 0; i < listSize; i++) { + Uri url = urls.get(i); + MediaPlaylistBundle bundle = new MediaPlaylistBundle(url); + playlistBundles.put(url, bundle); + } + } + + /** + * Called by the bundles when a snapshot changes. + * + * @param url The url of the playlist. + * @param newSnapshot The new snapshot. + */ + private void onPlaylistUpdated(Uri url, HlsMediaPlaylist newSnapshot) { + if (url.equals(primaryMediaPlaylistUrl)) { + if (primaryMediaPlaylistSnapshot == null) { + // This is the first primary url snapshot. + isLive = !newSnapshot.hasEndTag; + initialStartTimeUs = newSnapshot.startTimeUs; + } + primaryMediaPlaylistSnapshot = newSnapshot; + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot); + } + int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistChanged(); + } + } + + private boolean notifyPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + int listenersSize = listeners.size(); + boolean anyBlacklistingFailed = false; + for (int i = 0; i < listenersSize; i++) { + anyBlacklistingFailed |= !listeners.get(i).onPlaylistError(playlistUrl, blacklistDurationMs); + } + return anyBlacklistingFailed; + } + + private HlsMediaPlaylist getLatestPlaylistSnapshot( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (!loadedPlaylist.isNewerThan(oldPlaylist)) { + if (loadedPlaylist.hasEndTag) { + // If the loaded playlist has an end tag but is not newer than the old playlist then we have + // an inconsistent state. This is typically caused by the server incorrectly resetting the + // media sequence when appending the end tag. We resolve this case as best we can by + // returning the old playlist with the end tag appended. + return oldPlaylist.copyWithEndTag(); + } else { + return oldPlaylist; + } + } + long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist); + int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist); + return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence); + } + + private long getLoadedPlaylistStartTimeUs( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasProgramDateTime) { + return loadedPlaylist.startTimeUs; + } + long primarySnapshotStartTimeUs = + primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0; + if (oldPlaylist == null) { + return primarySnapshotStartTimeUs; + } + int oldPlaylistSize = oldPlaylist.segments.size(); + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs; + } else if (oldPlaylistSize == loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence) { + return oldPlaylist.getEndTimeUs(); + } else { + // No segments overlap, we assume the new playlist start coincides with the primary playlist. + return primarySnapshotStartTimeUs; + } + } + + private int getLoadedPlaylistDiscontinuitySequence( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasDiscontinuitySequence) { + return loadedPlaylist.discontinuitySequence; + } + // TODO: Improve cross-playlist discontinuity adjustment. + int primaryUrlDiscontinuitySequence = + primaryMediaPlaylistSnapshot != null + ? primaryMediaPlaylistSnapshot.discontinuitySequence + : 0; + if (oldPlaylist == null) { + return primaryUrlDiscontinuitySequence; + } + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.discontinuitySequence + + firstOldOverlappingSegment.relativeDiscontinuitySequence + - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence; + } + return primaryUrlDiscontinuitySequence; + } + + private static Segment getFirstOldOverlappingSegment( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence); + List<Segment> oldSegments = oldPlaylist.segments; + return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null; + } + + /** Holds all information related to a specific Media Playlist. */ + private final class MediaPlaylistBundle + implements Loader.Callback<ParsingLoadable<HlsPlaylist>>, Runnable { + + private final Uri playlistUrl; + private final Loader mediaPlaylistLoader; + private final ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable; + + @Nullable private HlsMediaPlaylist playlistSnapshot; + private long lastSnapshotLoadMs; + private long lastSnapshotChangeMs; + private long earliestNextLoadTimeMs; + private long blacklistUntilMs; + private boolean loadPending; + private IOException playlistError; + + public MediaPlaylistBundle(Uri playlistUrl) { + this.playlistUrl = playlistUrl; + mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + playlistUrl, + C.DATA_TYPE_MANIFEST, + mediaPlaylistParser); + } + + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot() { + return playlistSnapshot; + } + + public boolean isSnapshotValid() { + if (playlistSnapshot == null) { + return false; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + long snapshotValidityDurationMs = Math.max(30000, C.usToMs(playlistSnapshot.durationUs)); + return playlistSnapshot.hasEndTag + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD + || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs; + } + + public void release() { + mediaPlaylistLoader.release(); + } + + public void loadPlaylist() { + blacklistUntilMs = 0; + if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) { + // Load already pending, in progress, or a fatal error has been encountered. Do nothing. + return; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + if (currentTimeMs < earliestNextLoadTimeMs) { + loadPending = true; + playlistRefreshHandler.postDelayed(this, earliestNextLoadTimeMs - currentTimeMs); + } else { + loadPlaylistImmediately(); + } + } + + public void maybeThrowPlaylistRefreshError() throws IOException { + mediaPlaylistLoader.maybeThrowError(); + if (playlistError != null) { + throw playlistError; + } + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + if (result instanceof HlsMediaPlaylist) { + processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } else { + playlistError = new ParserException("Loaded playlist has unexpected type."); + } + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + LoadErrorAction loadErrorAction; + + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean shouldBlacklist = blacklistDurationMs != C.TIME_UNSET; + + boolean blacklistingFailed = + notifyPlaylistError(playlistUrl, blacklistDurationMs) || !shouldBlacklist; + if (shouldBlacklist) { + blacklistingFailed |= blacklistPlaylist(blacklistDurationMs); + } + + if (blacklistingFailed) { + long retryDelay = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } else { + loadErrorAction = Loader.DONT_RETRY; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + /* wasCanceled= */ !loadErrorAction.isRetry()); + + return loadErrorAction; + } + + // Runnable implementation. + + @Override + public void run() { + loadPending = false; + loadPlaylistImmediately(); + } + + // Internal methods. + + private void loadPlaylistImmediately() { + long elapsedRealtime = + mediaPlaylistLoader.startLoading( + mediaPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type)); + eventDispatcher.loadStarted( + mediaPlaylistLoadable.dataSpec, + mediaPlaylistLoadable.type, + elapsedRealtime); + } + + private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) { + HlsMediaPlaylist oldPlaylist = playlistSnapshot; + long currentTimeMs = SystemClock.elapsedRealtime(); + lastSnapshotLoadMs = currentTimeMs; + playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist); + if (playlistSnapshot != oldPlaylist) { + playlistError = null; + lastSnapshotChangeMs = currentTimeMs; + onPlaylistUpdated(playlistUrl, playlistSnapshot); + } else if (!playlistSnapshot.hasEndTag) { + if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size() + < playlistSnapshot.mediaSequence) { + // TODO: Allow customization of playlist resets handling. + // The media sequence jumped backwards. The server has probably reset. We do not try + // blacklisting in this case. + playlistError = new PlaylistResetException(playlistUrl); + notifyPlaylistError(playlistUrl, C.TIME_UNSET); + } else if (currentTimeMs - lastSnapshotChangeMs + > C.usToMs(playlistSnapshot.targetDurationUs) + * playlistStuckTargetDurationCoefficient) { + // TODO: Allow customization of stuck playlists handling. + playlistError = new PlaylistStuckException(playlistUrl); + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1); + notifyPlaylistError(playlistUrl, blacklistDurationMs); + if (blacklistDurationMs != C.TIME_UNSET) { + blacklistPlaylist(blacklistDurationMs); + } + } + } + // Do not allow the playlist to load again within the target duration if we obtained a new + // snapshot, or half the target duration otherwise. + earliestNextLoadTimeMs = + currentTimeMs + + C.usToMs( + playlistSnapshot != oldPlaylist + ? playlistSnapshot.targetDurationUs + : (playlistSnapshot.targetDurationUs / 2)); + // Schedule a load if this is the primary playlist and it doesn't have an end tag. Else the + // next load will be scheduled when refreshPlaylist is called, or when this playlist becomes + // the primary. + if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) { + loadPlaylist(); + } + } + + /** + * Blacklists the playlist. + * + * @param blacklistDurationMs The number of milliseconds for which the playlist should be + * blacklisted. + * @return Whether the playlist is the primary, despite being blacklisted. + */ + private boolean blacklistPlaylist(long blacklistDurationMs) { + blacklistUntilMs = SystemClock.elapsedRealtime() + blacklistDurationMs; + return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..a8c9ea1756 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilteringManifestParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import java.util.List; + +/** + * A {@link HlsPlaylistParserFactory} that includes only the streams identified by the given stream + * keys. + */ +public final class FilteringHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + private final HlsPlaylistParserFactory hlsPlaylistParserFactory; + private final List<StreamKey> streamKeys; + + /** + * @param hlsPlaylistParserFactory A factory for the parsers of the playlists which will be + * filtered. + * @param streamKeys The stream keys. If null or empty then filtering will not occur. + */ + public FilteringHlsPlaylistParserFactory( + HlsPlaylistParserFactory hlsPlaylistParserFactory, List<StreamKey> streamKeys) { + this.hlsPlaylistParserFactory = hlsPlaylistParserFactory; + this.streamKeys = streamKeys; + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(), streamKeys); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(masterPlaylist), streamKeys); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java new file mode 100644 index 0000000000..376f2b4301 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Represents an HLS master playlist. */ +public final class HlsMasterPlaylist extends HlsPlaylist { + + /** Represents an empty master playlist, from which no attributes can be inherited. */ + public static final HlsMasterPlaylist EMPTY = + new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + /* variants= */ Collections.emptyList(), + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + + // These constants must not be changed because they are persisted in offline stream keys. + public static final int GROUP_INDEX_VARIANT = 0; + public static final int GROUP_INDEX_AUDIO = 1; + public static final int GROUP_INDEX_SUBTITLE = 2; + + /** A variant (i.e. an #EXT-X-STREAM-INF tag) in a master playlist. */ + public static final class Variant { + + /** The variant's url. */ + public final Uri url; + + /** Format information associated with this variant. */ + public final Format format; + + /** The video rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String videoGroupId; + + /** The audio rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String audioGroupId; + + /** The subtitle rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String subtitleGroupId; + + /** The caption rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String captionGroupId; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param videoGroupId See {@link #videoGroupId}. + * @param audioGroupId See {@link #audioGroupId}. + * @param subtitleGroupId See {@link #subtitleGroupId}. + * @param captionGroupId See {@link #captionGroupId}. + */ + public Variant( + Uri url, + Format format, + @Nullable String videoGroupId, + @Nullable String audioGroupId, + @Nullable String subtitleGroupId, + @Nullable String captionGroupId) { + this.url = url; + this.format = format; + this.videoGroupId = videoGroupId; + this.audioGroupId = audioGroupId; + this.subtitleGroupId = subtitleGroupId; + this.captionGroupId = captionGroupId; + } + + /** + * Creates a variant for a given media playlist url. + * + * @param url The media playlist url. + * @return The variant instance. + */ + public static Variant createMediaPlaylistVariantUrl(Uri url) { + Format format = + Format.createContainerFormat( + "0", + /* label= */ null, + MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + /* language= */ null); + return new Variant( + url, + format, + /* videoGroupId= */ null, + /* audioGroupId= */ null, + /* subtitleGroupId= */ null, + /* captionGroupId= */ null); + } + + /** Returns a copy of this instance with the given {@link Format}. */ + public Variant copyWithFormat(Format format) { + return new Variant(url, format, videoGroupId, audioGroupId, subtitleGroupId, captionGroupId); + } + } + + /** A rendition (i.e. an #EXT-X-MEDIA tag) in a master playlist. */ + public static final class Rendition { + + /** The rendition's url, or null if the tag does not have a URI attribute. */ + @Nullable public final Uri url; + + /** Format information associated with this rendition. */ + public final Format format; + + /** The group to which this rendition belongs. */ + public final String groupId; + + /** The name of the rendition. */ + public final String name; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param groupId See {@link #groupId}. + * @param name See {@link #name}. + */ + public Rendition(@Nullable Uri url, Format format, String groupId, String name) { + this.url = url; + this.format = format; + this.groupId = groupId; + this.name = name; + } + + } + + /** All of the media playlist URLs referenced by the playlist. */ + public final List<Uri> mediaPlaylistUrls; + /** The variants declared by the playlist. */ + public final List<Variant> variants; + /** The video renditions declared by the playlist. */ + public final List<Rendition> videos; + /** The audio renditions declared by the playlist. */ + public final List<Rendition> audios; + /** The subtitle renditions declared by the playlist. */ + public final List<Rendition> subtitles; + /** The closed caption renditions declared by the playlist. */ + public final List<Rendition> closedCaptions; + + /** + * The format of the audio muxed in the variants. May be null if the playlist does not declare any + * muxed audio. + */ + @Nullable public final Format muxedAudioFormat; + /** + * The format of the closed captions declared by the playlist. May be empty if the playlist + * explicitly declares no captions are available, or null if the playlist does not declare any + * captions information. + */ + @Nullable public final List<Format> muxedCaptionFormats; + /** Contains variable definitions, as defined by the #EXT-X-DEFINE tag. */ + public final Map<String, String> variableDefinitions; + /** DRM initialization data derived from #EXT-X-SESSION-KEY tags. */ + public final List<DrmInitData> sessionKeyDrmInitData; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param variants See {@link #variants}. + * @param videos See {@link #videos}. + * @param audios See {@link #audios}. + * @param subtitles See {@link #subtitles}. + * @param closedCaptions See {@link #closedCaptions}. + * @param muxedAudioFormat See {@link #muxedAudioFormat}. + * @param muxedCaptionFormats See {@link #muxedCaptionFormats}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param variableDefinitions See {@link #variableDefinitions}. + * @param sessionKeyDrmInitData See {@link #sessionKeyDrmInitData}. + */ + public HlsMasterPlaylist( + String baseUri, + List<String> tags, + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions, + @Nullable Format muxedAudioFormat, + @Nullable List<Format> muxedCaptionFormats, + boolean hasIndependentSegments, + Map<String, String> variableDefinitions, + List<DrmInitData> sessionKeyDrmInitData) { + super(baseUri, tags, hasIndependentSegments); + this.mediaPlaylistUrls = + Collections.unmodifiableList( + getMediaPlaylistUrls(variants, videos, audios, subtitles, closedCaptions)); + this.variants = Collections.unmodifiableList(variants); + this.videos = Collections.unmodifiableList(videos); + this.audios = Collections.unmodifiableList(audios); + this.subtitles = Collections.unmodifiableList(subtitles); + this.closedCaptions = Collections.unmodifiableList(closedCaptions); + this.muxedAudioFormat = muxedAudioFormat; + this.muxedCaptionFormats = muxedCaptionFormats != null + ? Collections.unmodifiableList(muxedCaptionFormats) : null; + this.variableDefinitions = Collections.unmodifiableMap(variableDefinitions); + this.sessionKeyDrmInitData = Collections.unmodifiableList(sessionKeyDrmInitData); + } + + @Override + public HlsMasterPlaylist copy(List<StreamKey> streamKeys) { + return new HlsMasterPlaylist( + baseUri, + tags, + copyStreams(variants, GROUP_INDEX_VARIANT, streamKeys), + // TODO: Allow stream keys to specify video renditions to be retained. + /* videos= */ Collections.emptyList(), + copyStreams(audios, GROUP_INDEX_AUDIO, streamKeys), + copyStreams(subtitles, GROUP_INDEX_SUBTITLE, streamKeys), + // TODO: Update to retain all closed captions. + /* closedCaptions= */ Collections.emptyList(), + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegments, + variableDefinitions, + sessionKeyDrmInitData); + } + + /** + * Creates a playlist with a single variant. + * + * @param variantUrl The url of the single variant. + * @return A master playlist with a single variant for the provided url. + */ + public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUrl) { + List<Variant> variant = + Collections.singletonList(Variant.createMediaPlaylistVariantUrl(Uri.parse(variantUrl))); + return new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + variant, + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ null, + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + } + + private static List<Uri> getMediaPlaylistUrls( + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions) { + ArrayList<Uri> mediaPlaylistUrls = new ArrayList<>(); + for (int i = 0; i < variants.size(); i++) { + Uri uri = variants.get(i).url; + if (!mediaPlaylistUrls.contains(uri)) { + mediaPlaylistUrls.add(uri); + } + } + addMediaPlaylistUrls(videos, mediaPlaylistUrls); + addMediaPlaylistUrls(audios, mediaPlaylistUrls); + addMediaPlaylistUrls(subtitles, mediaPlaylistUrls); + addMediaPlaylistUrls(closedCaptions, mediaPlaylistUrls); + return mediaPlaylistUrls; + } + + private static void addMediaPlaylistUrls(List<Rendition> renditions, List<Uri> out) { + for (int i = 0; i < renditions.size(); i++) { + Uri uri = renditions.get(i).url; + if (uri != null && !out.contains(uri)) { + out.add(uri); + } + } + } + + private static <T> List<T> copyStreams( + List<T> streams, int groupIndex, List<StreamKey> streamKeys) { + List<T> copiedStreams = new ArrayList<>(streamKeys.size()); + // TODO: + // 1. When variants with the same URL are not de-duplicated, duplicates must not increment + // trackIndex so as to avoid breaking stream keys that have been persisted for offline. All + // duplicates should be copied if the first variant is copied, or discarded otherwise. + // 2. When renditions with null URLs are permitted, they must not increment trackIndex so as to + // avoid breaking stream keys that have been persisted for offline. All renitions with null + // URLs should be copied. They may become unreachable if all variants that reference them are + // removed, but this is OK. + // 3. Renditions with URLs matching copied variants should always themselves be copied, even if + // the corresponding stream key is omitted. Else we're throwing away information for no gain. + for (int i = 0; i < streams.size(); i++) { + T stream = streams.get(i); + for (int j = 0; j < streamKeys.size(); j++) { + StreamKey streamKey = streamKeys.get(j); + if (streamKey.groupIndex == groupIndex && streamKey.trackIndex == i) { + copiedStreams.add(stream); + break; + } + } + } + return copiedStreams; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java new file mode 100644 index 0000000000..c3250a5cc0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS media playlist. */ +public final class HlsMediaPlaylist extends HlsPlaylist { + + /** Media segment reference. */ + @SuppressWarnings("ComparableType") + public static final class Segment implements Comparable<Long> { + + /** + * The url of the segment. + */ + public final String url; + /** + * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if + * the media playlist does not define a media section for this segment. The same instance is + * used for all segments that share an EXT-X-MAP tag. + */ + @Nullable public final Segment initializationSegment; + /** The duration of the segment in microseconds, as defined by #EXTINF. */ + public final long durationUs; + /** The human readable title of the segment. */ + public final String title; + /** + * The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment. + */ + public final int relativeDiscontinuitySequence; + /** + * The start time of the segment in microseconds, relative to the start of the playlist. + */ + public final long relativeStartTimeUs; + /** + * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM + * protection. + */ + @Nullable public final DrmInitData drmInitData; + /** + * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use + * full segment encryption with identity key. + */ + @Nullable public final String fullSegmentEncryptionKeyUri; + /** + * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not + * encrypted. + */ + @Nullable public final String encryptionIV; + /** + * The segment's byte range offset, as defined by #EXT-X-BYTERANGE. + */ + public final long byterangeOffset; + /** + * The segment's byte range length, as defined by #EXT-X-BYTERANGE, or {@link C#LENGTH_UNSET} if + * no byte range is specified. + */ + public final long byterangeLength; + + /** Whether the segment is tagged with #EXT-X-GAP. */ + public final boolean hasGapTag; + + /** + * @param uri See {@link #url}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + */ + public Segment( + String uri, + long byterangeOffset, + long byterangeLength, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV) { + this( + uri, + /* initializationSegment= */ null, + /* title= */ "", + /* durationUs= */ 0, + /* relativeDiscontinuitySequence= */ -1, + /* relativeStartTimeUs= */ C.TIME_UNSET, + /* drmInitData= */ null, + fullSegmentEncryptionKeyUri, + encryptionIV, + byterangeOffset, + byterangeLength, + /* hasGapTag= */ false); + } + + /** + * @param url See {@link #url}. + * @param initializationSegment See {@link #initializationSegment}. + * @param title See {@link #title}. + * @param durationUs See {@link #durationUs}. + * @param relativeDiscontinuitySequence See {@link #relativeDiscontinuitySequence}. + * @param relativeStartTimeUs See {@link #relativeStartTimeUs}. + * @param drmInitData See {@link #drmInitData}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param hasGapTag See {@link #hasGapTag}. + */ + public Segment( + String url, + @Nullable Segment initializationSegment, + String title, + long durationUs, + int relativeDiscontinuitySequence, + long relativeStartTimeUs, + @Nullable DrmInitData drmInitData, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV, + long byterangeOffset, + long byterangeLength, + boolean hasGapTag) { + this.url = url; + this.initializationSegment = initializationSegment; + this.title = title; + this.durationUs = durationUs; + this.relativeDiscontinuitySequence = relativeDiscontinuitySequence; + this.relativeStartTimeUs = relativeStartTimeUs; + this.drmInitData = drmInitData; + this.fullSegmentEncryptionKeyUri = fullSegmentEncryptionKeyUri; + this.encryptionIV = encryptionIV; + this.byterangeOffset = byterangeOffset; + this.byterangeLength = byterangeLength; + this.hasGapTag = hasGapTag; + } + + @Override + public int compareTo(Long relativeStartTimeUs) { + return this.relativeStartTimeUs > relativeStartTimeUs + ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0); + } + + } + + /** + * Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link + * #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({PLAYLIST_TYPE_UNKNOWN, PLAYLIST_TYPE_VOD, PLAYLIST_TYPE_EVENT}) + public @interface PlaylistType {} + + public static final int PLAYLIST_TYPE_UNKNOWN = 0; + public static final int PLAYLIST_TYPE_VOD = 1; + public static final int PLAYLIST_TYPE_EVENT = 2; + + /** + * The type of the playlist. See {@link PlaylistType}. + */ + @PlaylistType public final int playlistType; + /** + * The start offset in microseconds, as defined by #EXT-X-START. + */ + public final long startOffsetUs; + /** + * If {@link #hasProgramDateTime} is true, contains the datetime as microseconds since epoch. + * Otherwise, contains the aggregated duration of removed segments up to this snapshot of the + * playlist. + */ + public final long startTimeUs; + /** + * Whether the playlist contains the #EXT-X-DISCONTINUITY-SEQUENCE tag. + */ + public final boolean hasDiscontinuitySequence; + /** + * The discontinuity sequence number of the first media segment in the playlist, as defined by + * #EXT-X-DISCONTINUITY-SEQUENCE. + */ + public final int discontinuitySequence; + /** + * The media sequence number of the first media segment in the playlist, as defined by + * #EXT-X-MEDIA-SEQUENCE. + */ + public final long mediaSequence; + /** + * The compatibility version, as defined by #EXT-X-VERSION. + */ + public final int version; + /** + * The target duration in microseconds, as defined by #EXT-X-TARGETDURATION. + */ + public final long targetDurationUs; + /** + * Whether the playlist contains the #EXT-X-ENDLIST tag. + */ + public final boolean hasEndTag; + /** + * Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag. + */ + public final boolean hasProgramDateTime; + /** + * Contains the CDM protection schemes used by segments in this playlist. Does not contain any key + * acquisition data. Null if none of the segments in the playlist is CDM-encrypted. + */ + @Nullable public final DrmInitData protectionSchemes; + /** + * The list of segments in the playlist. + */ + public final List<Segment> segments; + /** + * The total duration of the playlist in microseconds. + */ + public final long durationUs; + + /** + * @param playlistType See {@link #playlistType}. + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param startOffsetUs See {@link #startOffsetUs}. + * @param startTimeUs See {@link #startTimeUs}. + * @param hasDiscontinuitySequence See {@link #hasDiscontinuitySequence}. + * @param discontinuitySequence See {@link #discontinuitySequence}. + * @param mediaSequence See {@link #mediaSequence}. + * @param version See {@link #version}. + * @param targetDurationUs See {@link #targetDurationUs}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param hasEndTag See {@link #hasEndTag}. + * @param protectionSchemes See {@link #protectionSchemes}. + * @param hasProgramDateTime See {@link #hasProgramDateTime}. + * @param segments See {@link #segments}. + */ + public HlsMediaPlaylist( + @PlaylistType int playlistType, + String baseUri, + List<String> tags, + long startOffsetUs, + long startTimeUs, + boolean hasDiscontinuitySequence, + int discontinuitySequence, + long mediaSequence, + int version, + long targetDurationUs, + boolean hasIndependentSegments, + boolean hasEndTag, + boolean hasProgramDateTime, + @Nullable DrmInitData protectionSchemes, + List<Segment> segments) { + super(baseUri, tags, hasIndependentSegments); + this.playlistType = playlistType; + this.startTimeUs = startTimeUs; + this.hasDiscontinuitySequence = hasDiscontinuitySequence; + this.discontinuitySequence = discontinuitySequence; + this.mediaSequence = mediaSequence; + this.version = version; + this.targetDurationUs = targetDurationUs; + this.hasEndTag = hasEndTag; + this.hasProgramDateTime = hasProgramDateTime; + this.protectionSchemes = protectionSchemes; + this.segments = Collections.unmodifiableList(segments); + if (!segments.isEmpty()) { + Segment last = segments.get(segments.size() - 1); + durationUs = last.relativeStartTimeUs + last.durationUs; + } else { + durationUs = 0; + } + this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET + : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs; + } + + @Override + public HlsMediaPlaylist copy(List<StreamKey> streamKeys) { + return this; + } + + /** + * Returns whether this playlist is newer than {@code other}. + * + * @param other The playlist to compare. + * @return Whether this playlist is newer than {@code other}. + */ + public boolean isNewerThan(HlsMediaPlaylist other) { + if (other == null || mediaSequence > other.mediaSequence) { + return true; + } + if (mediaSequence < other.mediaSequence) { + return false; + } + // The media sequences are equal. + int segmentCount = segments.size(); + int otherSegmentCount = other.segments.size(); + return segmentCount > otherSegmentCount + || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag); + } + + /** + * Returns the result of adding the duration of the playlist to its start time. + */ + public long getEndTimeUs() { + return startTimeUs + durationUs; + } + + /** + * Returns a playlist identical to this one except for the start time, the discontinuity sequence + * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values, + * {@code hasDiscontinuitySequence} is set to true. + * + * @param startTimeUs The start time for the returned playlist. + * @param discontinuitySequence The discontinuity sequence for the returned playlist. + * @return An identical playlist including the provided discontinuity and timing information. + */ + public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + /* hasDiscontinuitySequence= */ true, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + hasEndTag, + hasProgramDateTime, + protectionSchemes, + segments); + } + + /** + * Returns a playlist identical to this one except that an end tag is added. If an end tag is + * already present then the playlist will return itself. + */ + public HlsMediaPlaylist copyWithEndTag() { + if (this.hasEndTag) { + return this; + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + hasDiscontinuitySequence, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + /* hasEndTag= */ true, + hasProgramDateTime, + protectionSchemes, + segments); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java new file mode 100644 index 0000000000..28f9b0eeb0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilterableManifest; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS playlist. */ +public abstract class HlsPlaylist implements FilterableManifest<HlsPlaylist> { + + /** + * The base uri. Used to resolve relative paths. + */ + public final String baseUri; + /** + * The list of tags in the playlist. + */ + public final List<String> tags; + /** + * Whether the media is formed of independent segments, as defined by the + * #EXT-X-INDEPENDENT-SEGMENTS tag. + */ + public final boolean hasIndependentSegments; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + */ + protected HlsPlaylist(String baseUri, List<String> tags, boolean hasIndependentSegments) { + this.baseUri = baseUri; + this.tags = Collections.unmodifiableList(tags); + this.hasIndependentSegments = hasIndependentSegments; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java new file mode 100644 index 0000000000..5495d28520 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -0,0 +1,1007 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.UnrecognizedInputFormatException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry.VariantInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Queue; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** + * HLS playlists parsing logic. + */ +public final class HlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> { + + private static final String PLAYLIST_HEADER = "#EXTM3U"; + + private static final String TAG_PREFIX = "#EXT"; + + private static final String TAG_VERSION = "#EXT-X-VERSION"; + private static final String TAG_PLAYLIST_TYPE = "#EXT-X-PLAYLIST-TYPE"; + private static final String TAG_DEFINE = "#EXT-X-DEFINE"; + private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF"; + private static final String TAG_MEDIA = "#EXT-X-MEDIA"; + private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION"; + private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY"; + private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE"; + private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME"; + private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP"; + private static final String TAG_INDEPENDENT_SEGMENTS = "#EXT-X-INDEPENDENT-SEGMENTS"; + private static final String TAG_MEDIA_DURATION = "#EXTINF"; + private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE"; + private static final String TAG_START = "#EXT-X-START"; + private static final String TAG_ENDLIST = "#EXT-X-ENDLIST"; + private static final String TAG_KEY = "#EXT-X-KEY"; + private static final String TAG_SESSION_KEY = "#EXT-X-SESSION-KEY"; + private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE"; + private static final String TAG_GAP = "#EXT-X-GAP"; + + private static final String TYPE_AUDIO = "AUDIO"; + private static final String TYPE_VIDEO = "VIDEO"; + private static final String TYPE_SUBTITLES = "SUBTITLES"; + private static final String TYPE_CLOSED_CAPTIONS = "CLOSED-CAPTIONS"; + + private static final String METHOD_NONE = "NONE"; + private static final String METHOD_AES_128 = "AES-128"; + private static final String METHOD_SAMPLE_AES = "SAMPLE-AES"; + // Replaced by METHOD_SAMPLE_AES_CTR. Keep for backward compatibility. + private static final String METHOD_SAMPLE_AES_CENC = "SAMPLE-AES-CENC"; + private static final String METHOD_SAMPLE_AES_CTR = "SAMPLE-AES-CTR"; + private static final String KEYFORMAT_PLAYREADY = "com.microsoft.playready"; + private static final String KEYFORMAT_IDENTITY = "identity"; + private static final String KEYFORMAT_WIDEVINE_PSSH_BINARY = + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"; + private static final String KEYFORMAT_WIDEVINE_PSSH_JSON = "com.widevine"; + + private static final String BOOLEAN_TRUE = "YES"; + private static final String BOOLEAN_FALSE = "NO"; + + private static final String ATTR_CLOSED_CAPTIONS_NONE = "CLOSED-CAPTIONS=NONE"; + + private static final Pattern REGEX_AVERAGE_BANDWIDTH = + Pattern.compile("AVERAGE-BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_VIDEO = Pattern.compile("VIDEO=\"(.+?)\""); + private static final Pattern REGEX_AUDIO = Pattern.compile("AUDIO=\"(.+?)\""); + private static final Pattern REGEX_SUBTITLES = Pattern.compile("SUBTITLES=\"(.+?)\""); + private static final Pattern REGEX_CLOSED_CAPTIONS = Pattern.compile("CLOSED-CAPTIONS=\"(.+?)\""); + private static final Pattern REGEX_BANDWIDTH = Pattern.compile("[^-]BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_CHANNELS = Pattern.compile("CHANNELS=\"(.+?)\""); + private static final Pattern REGEX_CODECS = Pattern.compile("CODECS=\"(.+?)\""); + private static final Pattern REGEX_RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)"); + private static final Pattern REGEX_FRAME_RATE = Pattern.compile("FRAME-RATE=([\\d\\.]+)\\b"); + private static final Pattern REGEX_TARGET_DURATION = Pattern.compile(TAG_TARGET_DURATION + + ":(\\d+)\\b"); + private static final Pattern REGEX_VERSION = Pattern.compile(TAG_VERSION + ":(\\d+)\\b"); + private static final Pattern REGEX_PLAYLIST_TYPE = Pattern.compile(TAG_PLAYLIST_TYPE + + ":(.+)\\b"); + private static final Pattern REGEX_MEDIA_SEQUENCE = Pattern.compile(TAG_MEDIA_SEQUENCE + + ":(\\d+)\\b"); + private static final Pattern REGEX_MEDIA_DURATION = Pattern.compile(TAG_MEDIA_DURATION + + ":([\\d\\.]+)\\b"); + private static final Pattern REGEX_MEDIA_TITLE = + Pattern.compile(TAG_MEDIA_DURATION + ":[\\d\\.]+\\b,(.+)"); + private static final Pattern REGEX_TIME_OFFSET = Pattern.compile("TIME-OFFSET=(-?[\\d\\.]+)\\b"); + private static final Pattern REGEX_BYTERANGE = Pattern.compile(TAG_BYTERANGE + + ":(\\d+(?:@\\d+)?)\\b"); + private static final Pattern REGEX_ATTR_BYTERANGE = + Pattern.compile("BYTERANGE=\"(\\d+(?:@\\d+)?)\\b\""); + private static final Pattern REGEX_METHOD = + Pattern.compile( + "METHOD=(" + + METHOD_NONE + + "|" + + METHOD_AES_128 + + "|" + + METHOD_SAMPLE_AES + + "|" + + METHOD_SAMPLE_AES_CENC + + "|" + + METHOD_SAMPLE_AES_CTR + + ")" + + "\\s*(?:,|$)"); + private static final Pattern REGEX_KEYFORMAT = Pattern.compile("KEYFORMAT=\"(.+?)\""); + private static final Pattern REGEX_KEYFORMATVERSIONS = + Pattern.compile("KEYFORMATVERSIONS=\"(.+?)\""); + private static final Pattern REGEX_URI = Pattern.compile("URI=\"(.+?)\""); + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + private static final Pattern REGEX_TYPE = Pattern.compile("TYPE=(" + TYPE_AUDIO + "|" + TYPE_VIDEO + + "|" + TYPE_SUBTITLES + "|" + TYPE_CLOSED_CAPTIONS + ")"); + private static final Pattern REGEX_LANGUAGE = Pattern.compile("LANGUAGE=\"(.+?)\""); + private static final Pattern REGEX_NAME = Pattern.compile("NAME=\"(.+?)\""); + private static final Pattern REGEX_GROUP_ID = Pattern.compile("GROUP-ID=\"(.+?)\""); + private static final Pattern REGEX_CHARACTERISTICS = Pattern.compile("CHARACTERISTICS=\"(.+?)\""); + private static final Pattern REGEX_INSTREAM_ID = + Pattern.compile("INSTREAM-ID=\"((?:CC|SERVICE)\\d+)\""); + private static final Pattern REGEX_AUTOSELECT = compileBooleanAttrPattern("AUTOSELECT"); + private static final Pattern REGEX_DEFAULT = compileBooleanAttrPattern("DEFAULT"); + private static final Pattern REGEX_FORCED = compileBooleanAttrPattern("FORCED"); + private static final Pattern REGEX_VALUE = Pattern.compile("VALUE=\"(.+?)\""); + private static final Pattern REGEX_IMPORT = Pattern.compile("IMPORT=\"(.+?)\""); + private static final Pattern REGEX_VARIABLE_REFERENCE = + Pattern.compile("\\{\\$([a-zA-Z0-9\\-_]+)\\}"); + + private final HlsMasterPlaylist masterPlaylist; + + /** + * Creates an instance where media playlists are parsed without inheriting attributes from a + * master playlist. + */ + public HlsPlaylistParser() { + this(HlsMasterPlaylist.EMPTY); + } + + /** + * Creates an instance where parsed media playlists inherit attributes from the given master + * playlist. + * + * @param masterPlaylist The master playlist from which media playlists will inherit attributes. + */ + public HlsPlaylistParser(HlsMasterPlaylist masterPlaylist) { + this.masterPlaylist = masterPlaylist; + } + + @Override + public HlsPlaylist parse(Uri uri, InputStream inputStream) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + Queue<String> extraLines = new ArrayDeque<>(); + String line; + try { + if (!checkPlaylistHeader(reader)) { + throw new UnrecognizedInputFormatException("Input does not start with the #EXTM3U header.", + uri); + } + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + // Do nothing. + } else if (line.startsWith(TAG_STREAM_INF)) { + extraLines.add(line); + return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString()); + } else if (line.startsWith(TAG_TARGET_DURATION) + || line.startsWith(TAG_MEDIA_SEQUENCE) + || line.startsWith(TAG_MEDIA_DURATION) + || line.startsWith(TAG_KEY) + || line.startsWith(TAG_BYTERANGE) + || line.equals(TAG_DISCONTINUITY) + || line.equals(TAG_DISCONTINUITY_SEQUENCE) + || line.equals(TAG_ENDLIST)) { + extraLines.add(line); + return parseMediaPlaylist( + masterPlaylist, new LineIterator(extraLines, reader), uri.toString()); + } else { + extraLines.add(line); + } + } + } finally { + Util.closeQuietly(reader); + } + throw new ParserException("Failed to parse the playlist, could not identify any tags."); + } + + private static boolean checkPlaylistHeader(BufferedReader reader) throws IOException { + int last = reader.read(); + if (last == 0xEF) { + if (reader.read() != 0xBB || reader.read() != 0xBF) { + return false; + } + // The playlist contains a Byte Order Mark, which gets discarded. + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, true, last); + int playlistHeaderLength = PLAYLIST_HEADER.length(); + for (int i = 0; i < playlistHeaderLength; i++) { + if (last != PLAYLIST_HEADER.charAt(i)) { + return false; + } + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, false, last); + return Util.isLinebreak(last); + } + + private static int skipIgnorableWhitespace(BufferedReader reader, boolean skipLinebreaks, int c) + throws IOException { + while (c != -1 && Character.isWhitespace(c) && (skipLinebreaks || !Util.isLinebreak(c))) { + c = reader.read(); + } + return c; + } + + private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, String baseUri) + throws IOException { + HashMap<Uri, ArrayList<VariantInfo>> urlToVariantInfos = new HashMap<>(); + HashMap<String, String> variableDefinitions = new HashMap<>(); + ArrayList<Variant> variants = new ArrayList<>(); + ArrayList<Rendition> videos = new ArrayList<>(); + ArrayList<Rendition> audios = new ArrayList<>(); + ArrayList<Rendition> subtitles = new ArrayList<>(); + ArrayList<Rendition> closedCaptions = new ArrayList<>(); + ArrayList<String> mediaTags = new ArrayList<>(); + ArrayList<DrmInitData> sessionKeyDrmInitData = new ArrayList<>(); + ArrayList<String> tags = new ArrayList<>(); + Format muxedAudioFormat = null; + List<Format> muxedCaptionFormats = null; + boolean noClosedCaptions = false; + boolean hasIndependentSegmentsTag = false; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_DEFINE)) { + variableDefinitions.put( + /* key= */ parseStringAttr(line, REGEX_NAME, variableDefinitions), + /* value= */ parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.startsWith(TAG_MEDIA)) { + // Media tags are parsed at the end to include codec information from #EXT-X-STREAM-INF + // tags. + mediaTags.add(line); + } else if (line.startsWith(TAG_SESSION_KEY)) { + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String scheme = parseEncryptionScheme(method); + sessionKeyDrmInitData.add(new DrmInitData(scheme, schemeData)); + } + } else if (line.startsWith(TAG_STREAM_INF)) { + noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE); + int bitrate = parseIntAttr(line, REGEX_BANDWIDTH); + // TODO: Plumb this into Format. + int averageBitrate = parseOptionalIntAttr(line, REGEX_AVERAGE_BANDWIDTH, -1); + String codecs = parseOptionalStringAttr(line, REGEX_CODECS, variableDefinitions); + String resolutionString = + parseOptionalStringAttr(line, REGEX_RESOLUTION, variableDefinitions); + int width; + int height; + if (resolutionString != null) { + String[] widthAndHeight = resolutionString.split("x"); + width = Integer.parseInt(widthAndHeight[0]); + height = Integer.parseInt(widthAndHeight[1]); + if (width <= 0 || height <= 0) { + // Resolution string is invalid. + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + } else { + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + float frameRate = Format.NO_VALUE; + String frameRateString = + parseOptionalStringAttr(line, REGEX_FRAME_RATE, variableDefinitions); + if (frameRateString != null) { + frameRate = Float.parseFloat(frameRateString); + } + String videoGroupId = parseOptionalStringAttr(line, REGEX_VIDEO, variableDefinitions); + String audioGroupId = parseOptionalStringAttr(line, REGEX_AUDIO, variableDefinitions); + String subtitlesGroupId = + parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions); + String closedCaptionsGroupId = + parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions); + if (!iterator.hasNext()) { + throw new ParserException("#EXT-X-STREAM-INF tag must be followed by another line"); + } + line = + replaceVariableReferences( + iterator.next(), variableDefinitions); // #EXT-X-STREAM-INF's URI. + Uri uri = UriUtil.resolveToUri(baseUri, line); + Format format = + Format.createVideoContainerFormat( + /* id= */ Integer.toString(variants.size()), + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + codecs, + /* metadata= */ null, + bitrate, + width, + height, + frameRate, + /* initializationData= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0); + Variant variant = + new Variant( + uri, format, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId); + variants.add(variant); + ArrayList<VariantInfo> variantInfosForUrl = urlToVariantInfos.get(uri); + if (variantInfosForUrl == null) { + variantInfosForUrl = new ArrayList<>(); + urlToVariantInfos.put(uri, variantInfosForUrl); + } + variantInfosForUrl.add( + new VariantInfo( + bitrate, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId)); + } + } + + // TODO: Don't deduplicate variants by URL. + ArrayList<Variant> deduplicatedVariants = new ArrayList<>(); + HashSet<Uri> urlsInDeduplicatedVariants = new HashSet<>(); + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (urlsInDeduplicatedVariants.add(variant.url)) { + Assertions.checkState(variant.format.metadata == null); + HlsTrackMetadataEntry hlsMetadataEntry = + new HlsTrackMetadataEntry( + /* groupId= */ null, + /* name= */ null, + Assertions.checkNotNull(urlToVariantInfos.get(variant.url))); + deduplicatedVariants.add( + variant.copyWithFormat( + variant.format.copyWithMetadata(new Metadata(hlsMetadataEntry)))); + } + } + + for (int i = 0; i < mediaTags.size(); i++) { + line = mediaTags.get(i); + String groupId = parseStringAttr(line, REGEX_GROUP_ID, variableDefinitions); + String name = parseStringAttr(line, REGEX_NAME, variableDefinitions); + String referenceUri = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions); + Uri uri = referenceUri == null ? null : UriUtil.resolveToUri(baseUri, referenceUri); + String language = parseOptionalStringAttr(line, REGEX_LANGUAGE, variableDefinitions); + @C.SelectionFlags int selectionFlags = parseSelectionFlags(line); + @C.RoleFlags int roleFlags = parseRoleFlags(line, variableDefinitions); + String formatId = groupId + ":" + name; + Format format; + Metadata metadata = + new Metadata(new HlsTrackMetadataEntry(groupId, name, Collections.emptyList())); + switch (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) { + case TYPE_VIDEO: + Variant variant = getVariantWithVideoGroup(variants, groupId); + String codecs = null; + int width = Format.NO_VALUE; + int height = Format.NO_VALUE; + float frameRate = Format.NO_VALUE; + if (variant != null) { + Format variantFormat = variant.format; + codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO); + width = variantFormat.width; + height = variantFormat.height; + frameRate = variantFormat.frameRate; + } + String sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + format = + Format.createVideoContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + width, + height, + frameRate, + /* initializationData= */ null, + selectionFlags, + roleFlags) + .copyWithMetadata(metadata); + if (uri == null) { + // TODO: Remove this case and add a Rendition with a null uri to videos. + } else { + videos.add(new Rendition(uri, format, groupId, name)); + } + break; + case TYPE_AUDIO: + variant = getVariantWithAudioGroup(variants, groupId); + codecs = + variant != null + ? Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_AUDIO) + : null; + sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + String channelsString = + parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions); + int channelCount = Format.NO_VALUE; + if (channelsString != null) { + channelCount = Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]); + if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType) && channelsString.endsWith("/JOC")) { + sampleMimeType = MimeTypes.AUDIO_E_AC3_JOC; + } + } + format = + Format.createAudioContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + channelCount, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + selectionFlags, + roleFlags, + language); + if (uri == null) { + // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. + muxedAudioFormat = format; + } else { + audios.add(new Rendition(uri, format.copyWithMetadata(metadata), groupId, name)); + } + break; + case TYPE_SUBTITLES: + codecs = null; + sampleMimeType = null; + variant = getVariantWithSubtitleGroup(variants, groupId); + if (variant != null) { + codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_TEXT); + sampleMimeType = MimeTypes.getMediaMimeType(codecs); + } + if (sampleMimeType == null) { + sampleMimeType = MimeTypes.TEXT_VTT; + } + format = + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language) + .copyWithMetadata(metadata); + subtitles.add(new Rendition(uri, format, groupId, name)); + break; + case TYPE_CLOSED_CAPTIONS: + String instreamId = parseStringAttr(line, REGEX_INSTREAM_ID, variableDefinitions); + String mimeType; + int accessibilityChannel; + if (instreamId.startsWith("CC")) { + mimeType = MimeTypes.APPLICATION_CEA608; + accessibilityChannel = Integer.parseInt(instreamId.substring(2)); + } else /* starts with SERVICE */ { + mimeType = MimeTypes.APPLICATION_CEA708; + accessibilityChannel = Integer.parseInt(instreamId.substring(7)); + } + if (muxedCaptionFormats == null) { + muxedCaptionFormats = new ArrayList<>(); + } + muxedCaptionFormats.add( + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ null, + /* sampleMimeType= */ mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language, + accessibilityChannel)); + // TODO: Remove muxedCaptionFormats and add a Rendition with a null uri to closedCaptions. + break; + default: + // Do nothing. + break; + } + } + + if (noClosedCaptions) { + muxedCaptionFormats = Collections.emptyList(); + } + + return new HlsMasterPlaylist( + baseUri, + tags, + deduplicatedVariants, + videos, + audios, + subtitles, + closedCaptions, + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegmentsTag, + variableDefinitions, + sessionKeyDrmInitData); + } + + @Nullable + private static Variant getVariantWithAudioGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.audioGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithVideoGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.videoGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithSubtitleGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.subtitleGroupId)) { + return variant; + } + } + return null; + } + + private static HlsMediaPlaylist parseMediaPlaylist( + HlsMasterPlaylist masterPlaylist, LineIterator iterator, String baseUri) throws IOException { + @HlsMediaPlaylist.PlaylistType int playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN; + long startOffsetUs = C.TIME_UNSET; + long mediaSequence = 0; + int version = 1; // Default version == 1. + long targetDurationUs = C.TIME_UNSET; + boolean hasIndependentSegmentsTag = masterPlaylist.hasIndependentSegments; + boolean hasEndTag = false; + Segment initializationSegment = null; + HashMap<String, String> variableDefinitions = new HashMap<>(); + List<Segment> segments = new ArrayList<>(); + List<String> tags = new ArrayList<>(); + + long segmentDurationUs = 0; + String segmentTitle = ""; + boolean hasDiscontinuitySequence = false; + int playlistDiscontinuitySequence = 0; + int relativeDiscontinuitySequence = 0; + long playlistStartTimeUs = 0; + long segmentStartTimeUs = 0; + long segmentByteRangeOffset = 0; + long segmentByteRangeLength = C.LENGTH_UNSET; + long segmentMediaSequence = 0; + boolean hasGapTag = false; + + DrmInitData playlistProtectionSchemes = null; + String fullSegmentEncryptionKeyUri = null; + String fullSegmentEncryptionIV = null; + TreeMap<String, SchemeData> currentSchemeDatas = new TreeMap<>(); + String encryptionScheme = null; + DrmInitData cachedDrmInitData = null; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_PLAYLIST_TYPE)) { + String playlistTypeString = parseStringAttr(line, REGEX_PLAYLIST_TYPE, variableDefinitions); + if ("VOD".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD; + } else if ("EVENT".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT; + } + } else if (line.startsWith(TAG_START)) { + startOffsetUs = (long) (parseDoubleAttr(line, REGEX_TIME_OFFSET) * C.MICROS_PER_SECOND); + } else if (line.startsWith(TAG_INIT_SEGMENT)) { + String uri = parseStringAttr(line, REGEX_URI, variableDefinitions); + String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions); + if (byteRange != null) { + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } + if (fullSegmentEncryptionKeyUri != null && fullSegmentEncryptionIV == null) { + // See RFC 8216, Section 4.3.2.5. + throw new ParserException( + "The encryption IV attribute must be present when an initialization segment is " + + "encrypted with METHOD=AES-128."); + } + initializationSegment = + new Segment( + uri, + segmentByteRangeOffset, + segmentByteRangeLength, + fullSegmentEncryptionKeyUri, + fullSegmentEncryptionIV); + segmentByteRangeOffset = 0; + segmentByteRangeLength = C.LENGTH_UNSET; + } else if (line.startsWith(TAG_TARGET_DURATION)) { + targetDurationUs = parseIntAttr(line, REGEX_TARGET_DURATION) * C.MICROS_PER_SECOND; + } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) { + mediaSequence = parseLongAttr(line, REGEX_MEDIA_SEQUENCE); + segmentMediaSequence = mediaSequence; + } else if (line.startsWith(TAG_VERSION)) { + version = parseIntAttr(line, REGEX_VERSION); + } else if (line.startsWith(TAG_DEFINE)) { + String importName = parseOptionalStringAttr(line, REGEX_IMPORT, variableDefinitions); + if (importName != null) { + String value = masterPlaylist.variableDefinitions.get(importName); + if (value != null) { + variableDefinitions.put(importName, value); + } else { + // The master playlist does not declare the imported variable. Ignore. + } + } else { + variableDefinitions.put( + parseStringAttr(line, REGEX_NAME, variableDefinitions), + parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } + } else if (line.startsWith(TAG_MEDIA_DURATION)) { + segmentDurationUs = + (long) (parseDoubleAttr(line, REGEX_MEDIA_DURATION) * C.MICROS_PER_SECOND); + segmentTitle = parseOptionalStringAttr(line, REGEX_MEDIA_TITLE, "", variableDefinitions); + } else if (line.startsWith(TAG_KEY)) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + fullSegmentEncryptionKeyUri = null; + fullSegmentEncryptionIV = null; + if (METHOD_NONE.equals(method)) { + currentSchemeDatas.clear(); + cachedDrmInitData = null; + } else /* !METHOD_NONE.equals(method) */ { + fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions); + if (KEYFORMAT_IDENTITY.equals(keyFormat)) { + if (METHOD_AES_128.equals(method)) { + // The segment is fully encrypted using an identity key. + fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions); + } else { + // Do nothing. Samples are encrypted using an identity key, but this is not supported. + // Hopefully, a traditional DRM alternative is also provided. + } + } else { + if (encryptionScheme == null) { + encryptionScheme = parseEncryptionScheme(method); + } + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + cachedDrmInitData = null; + currentSchemeDatas.put(keyFormat, schemeData); + } + } + } + } else if (line.startsWith(TAG_BYTERANGE)) { + String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions); + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) { + hasDiscontinuitySequence = true; + playlistDiscontinuitySequence = Integer.parseInt(line.substring(line.indexOf(':') + 1)); + } else if (line.equals(TAG_DISCONTINUITY)) { + relativeDiscontinuitySequence++; + } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) { + if (playlistStartTimeUs == 0) { + long programDatetimeUs = + C.msToUs(Util.parseXsDateTime(line.substring(line.indexOf(':') + 1))); + playlistStartTimeUs = programDatetimeUs - segmentStartTimeUs; + } + } else if (line.equals(TAG_GAP)) { + hasGapTag = true; + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.equals(TAG_ENDLIST)) { + hasEndTag = true; + } else if (!line.startsWith("#")) { + String segmentEncryptionIV; + if (fullSegmentEncryptionKeyUri == null) { + segmentEncryptionIV = null; + } else if (fullSegmentEncryptionIV != null) { + segmentEncryptionIV = fullSegmentEncryptionIV; + } else { + segmentEncryptionIV = Long.toHexString(segmentMediaSequence); + } + + segmentMediaSequence++; + if (segmentByteRangeLength == C.LENGTH_UNSET) { + segmentByteRangeOffset = 0; + } + + if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) { + SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); + cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); + if (playlistProtectionSchemes == null) { + SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length]; + for (int i = 0; i < schemeDatas.length; i++) { + playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null); + } + playlistProtectionSchemes = new DrmInitData(encryptionScheme, playlistSchemeDatas); + } + } + + segments.add( + new Segment( + replaceVariableReferences(line, variableDefinitions), + initializationSegment, + segmentTitle, + segmentDurationUs, + relativeDiscontinuitySequence, + segmentStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + segmentByteRangeOffset, + segmentByteRangeLength, + hasGapTag)); + segmentStartTimeUs += segmentDurationUs; + segmentDurationUs = 0; + segmentTitle = ""; + if (segmentByteRangeLength != C.LENGTH_UNSET) { + segmentByteRangeOffset += segmentByteRangeLength; + } + segmentByteRangeLength = C.LENGTH_UNSET; + hasGapTag = false; + } + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + playlistStartTimeUs, + hasDiscontinuitySequence, + playlistDiscontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegmentsTag, + hasEndTag, + /* hasProgramDateTime= */ playlistStartTimeUs != 0, + playlistProtectionSchemes, + segments); + } + + @C.SelectionFlags + private static int parseSelectionFlags(String line) { + int flags = 0; + if (parseOptionalBooleanAttribute(line, REGEX_DEFAULT, false)) { + flags |= C.SELECTION_FLAG_DEFAULT; + } + if (parseOptionalBooleanAttribute(line, REGEX_FORCED, false)) { + flags |= C.SELECTION_FLAG_FORCED; + } + if (parseOptionalBooleanAttribute(line, REGEX_AUTOSELECT, false)) { + flags |= C.SELECTION_FLAG_AUTOSELECT; + } + return flags; + } + + @C.RoleFlags + private static int parseRoleFlags(String line, Map<String, String> variableDefinitions) { + String concatenatedCharacteristics = + parseOptionalStringAttr(line, REGEX_CHARACTERISTICS, variableDefinitions); + if (TextUtils.isEmpty(concatenatedCharacteristics)) { + return 0; + } + String[] characteristics = Util.split(concatenatedCharacteristics, ","); + @C.RoleFlags int roleFlags = 0; + if (Util.contains(characteristics, "public.accessibility.describes-video")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_VIDEO; + } + if (Util.contains(characteristics, "public.accessibility.transcribes-spoken-dialog")) { + roleFlags |= C.ROLE_FLAG_TRANSCRIBES_DIALOG; + } + if (Util.contains(characteristics, "public.accessibility.describes-music-and-sound")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; + } + if (Util.contains(characteristics, "public.easy-to-read")) { + roleFlags |= C.ROLE_FLAG_EASY_TO_READ; + } + return roleFlags; + } + + @Nullable + private static SchemeData parseDrmSchemeData( + String line, String keyFormat, Map<String, String> variableDefinitions) + throws ParserException { + String keyFormatVersions = + parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions); + if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + return new SchemeData( + C.WIDEVINE_UUID, + MimeTypes.VIDEO_MP4, + Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT)); + } else if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) { + return new SchemeData(C.WIDEVINE_UUID, "hls", Util.getUtf8Bytes(line)); + } else if (KEYFORMAT_PLAYREADY.equals(keyFormat) && "1".equals(keyFormatVersions)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + byte[] data = Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT); + byte[] psshData = PsshAtomUtil.buildPsshAtom(C.PLAYREADY_UUID, data); + return new SchemeData(C.PLAYREADY_UUID, MimeTypes.VIDEO_MP4, psshData); + } + return null; + } + + private static String parseEncryptionScheme(String method) { + return METHOD_SAMPLE_AES_CENC.equals(method) || METHOD_SAMPLE_AES_CTR.equals(method) + ? C.CENC_TYPE_cenc + : C.CENC_TYPE_cbcs; + } + + private static int parseIntAttr(String line, Pattern pattern) throws ParserException { + return Integer.parseInt(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static int parseOptionalIntAttr(String line, Pattern pattern, int defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + return defaultValue; + } + + private static long parseLongAttr(String line, Pattern pattern) throws ParserException { + return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException { + return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static String parseStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) + throws ParserException { + String value = parseOptionalStringAttr(line, pattern, variableDefinitions); + if (value != null) { + return value; + } else { + throw new ParserException("Couldn't match " + pattern.pattern() + " in " + line); + } + } + + private static @Nullable String parseOptionalStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) { + return parseOptionalStringAttr(line, pattern, null, variableDefinitions); + } + + private static @PolyNull String parseOptionalStringAttr( + String line, + Pattern pattern, + @PolyNull String defaultValue, + Map<String, String> variableDefinitions) { + Matcher matcher = pattern.matcher(line); + String value = matcher.find() ? matcher.group(1) : defaultValue; + return variableDefinitions.isEmpty() || value == null + ? value + : replaceVariableReferences(value, variableDefinitions); + } + + private static String replaceVariableReferences( + String string, Map<String, String> variableDefinitions) { + Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string); + // TODO: Replace StringBuffer with StringBuilder once Java 9 is available. + StringBuffer stringWithReplacements = new StringBuffer(); + while (matcher.find()) { + String groupName = matcher.group(1); + if (variableDefinitions.containsKey(groupName)) { + matcher.appendReplacement( + stringWithReplacements, Matcher.quoteReplacement(variableDefinitions.get(groupName))); + } else { + // The variable is not defined. The value is ignored. + } + } + matcher.appendTail(stringWithReplacements); + return stringWithReplacements.toString(); + } + + private static boolean parseOptionalBooleanAttribute( + String line, Pattern pattern, boolean defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return matcher.group(1).equals(BOOLEAN_TRUE); + } + return defaultValue; + } + + private static Pattern compileBooleanAttrPattern(String attribute) { + return Pattern.compile(attribute + "=(" + BOOLEAN_FALSE + "|" + BOOLEAN_TRUE + ")"); + } + + private static class LineIterator { + + private final BufferedReader reader; + private final Queue<String> extraLines; + + @Nullable private String next; + + public LineIterator(Queue<String> extraLines, BufferedReader reader) { + this.extraLines = extraLines; + this.reader = reader; + } + + @EnsuresNonNullIf(expression = "next", result = true) + public boolean hasNext() throws IOException { + if (next != null) { + return true; + } + if (!extraLines.isEmpty()) { + next = Assertions.checkNotNull(extraLines.poll()); + return true; + } + while ((next = reader.readLine()) != null) { + next = next.trim(); + if (!next.isEmpty()) { + return true; + } + } + return false; + } + + /** Return the next line, or throw {@link NoSuchElementException} if none. */ + public String next() throws IOException { + if (hasNext()) { + String result = next; + next = null; + return result; + } else { + throw new NoSuchElementException(); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java new file mode 100644 index 0000000000..deb1daf8a7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Factory for {@link HlsPlaylist} parsers. */ +public interface HlsPlaylistParserFactory { + + /** + * Returns a stand-alone playlist parser. Playlists parsed by the returned parser do not inherit + * any attributes from other playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(); + + /** + * Returns a playlist parser for playlists that were referenced by the given {@link + * HlsMasterPlaylist}. Returned {@link HlsMediaPlaylist} instances may inherit attributes from + * {@code masterPlaylist}. + * + * @param masterPlaylist The master playlist that referenced any parsed media playlists. + * @return A parser for HLS playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(HlsMasterPlaylist masterPlaylist); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java new file mode 100644 index 0000000000..69f8cb02c9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import java.io.IOException; + +/** + * Tracks playlists associated to an HLS stream and provides snapshots. + * + * <p>The playlist tracker is responsible for exposing the seeking window, which is defined by the + * segments that one of the playlists exposes. This playlist is called primary and needs to be + * periodically refreshed in the case of live streams. Note that the primary playlist is one of the + * media playlists while the master playlist is an optional kind of playlist defined by the HLS + * specification (RFC 8216). + * + * <p>Playlist loads might encounter errors. The tracker may choose to blacklist them to ensure a + * primary playlist is always available. + */ +public interface HlsPlaylistTracker { + + /** Factory for {@link HlsPlaylistTracker} instances. */ + interface Factory { + + /** + * Creates a new tracker instance. + * + * @param dataSourceFactory The {@link HlsDataSourceFactory} to use for playlist loading. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy} for playlist load errors. + * @param playlistParserFactory The {@link HlsPlaylistParserFactory} for playlist parsing. + */ + HlsPlaylistTracker createTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory); + } + + /** Listener for primary playlist changes. */ + interface PrimaryPlaylistListener { + + /** + * Called when the primary playlist changes. + * + * @param mediaPlaylist The primary playlist new snapshot. + */ + void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist); + } + + /** Called on playlist loading events. */ + interface PlaylistEventListener { + + /** + * Called a playlist changes. + */ + void onPlaylistChanged(); + + /** + * Called if an error is encountered while loading a playlist. + * + * @param url The loaded url that caused the error. + * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or + * {@link C#TIME_UNSET} if the playlist should not be blacklisted. + * @return True if blacklisting did not encounter errors. False otherwise. + */ + boolean onPlaylistError(Uri url, long blacklistDurationMs); + } + + /** Thrown when a playlist is considered to be stuck due to a server side error. */ + final class PlaylistStuckException extends IOException { + + /** The url of the stuck playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistStuckException(Uri url) { + this.url = url; + } + } + + /** Thrown when the media sequence of a new snapshot indicates the server has reset. */ + final class PlaylistResetException extends IOException { + + /** The url of the reset playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistResetException(Uri url) { + this.url = url; + } + } + + /** + * Starts the playlist tracker. + * + * <p>Must be called from the playback thread. A tracker may be restarted after a {@link #stop()} + * call. + * + * @param initialPlaylistUri Uri of the HLS stream. Can point to a media playlist or a master + * playlist. + * @param eventDispatcher A dispatcher to notify of events. + * @param listener A callback for the primary playlist change events. + */ + void start( + Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener listener); + + /** + * Stops the playlist tracker and releases any acquired resources. + * + * <p>Must be called once per {@link #start} call. + */ + void stop(); + + /** + * Registers a listener to receive events from the playlist tracker. + * + * @param listener The listener. + */ + void addListener(PlaylistEventListener listener); + + /** + * Unregisters a listener. + * + * @param listener The listener to unregister. + */ + void removeListener(PlaylistEventListener listener); + + /** + * Returns the master playlist. + * + * <p>If the uri passed to {@link #start} points to a media playlist, an {@link HlsMasterPlaylist} + * with a single variant for said media playlist is returned. + * + * @return The master playlist. Null if the initial playlist has yet to be loaded. + */ + @Nullable + HlsMasterPlaylist getMasterPlaylist(); + + /** + * Returns the most recent snapshot available of the playlist referenced by the provided {@link + * Uri}. + * + * @param url The {@link Uri} corresponding to the requested media playlist. + * @param isForPlayback Whether the caller might use the snapshot to request media segments for + * playback. If true, the primary playlist may be updated to the one requested. + * @return The most recent snapshot of the playlist referenced by the provided {@link Uri}. May be + * null if no snapshot has been loaded yet. + */ + @Nullable + HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback); + + /** + * Returns the start time of the first loaded primary playlist, or {@link C#TIME_UNSET} if no + * media playlist has been loaded. + */ + long getInitialStartTimeUs(); + + /** + * Returns whether the snapshot of the playlist referenced by the provided {@link Uri} is valid, + * meaning all the segments referenced by the playlist are expected to be available. If the + * playlist is not valid then some of the segments may no longer be available. + * + * @param url The {@link Uri}. + * @return Whether the snapshot of the playlist referenced by the provided {@link Uri} is valid. + */ + boolean isSnapshotValid(Uri url); + + /** + * If the tracker is having trouble refreshing the master playlist or the primary playlist, this + * method throws the underlying error. Otherwise, does nothing. + * + * @throws IOException The underlying error. + */ + void maybeThrowPrimaryPlaylistRefreshError() throws IOException; + + /** + * If the playlist is having trouble refreshing the playlist referenced by the given {@link Uri}, + * this method throws the underlying error. + * + * @param url The {@link Uri}. + * @throws IOException The underyling error. + */ + void maybeThrowPlaylistRefreshError(Uri url) throws IOException; + + /** + * Requests a playlist refresh and whitelists it. + * + * <p>The playlist tracker may choose the delay the playlist refresh. The request is discarded if + * a refresh was already pending. + * + * @param url The {@link Uri} of the playlist to be refreshed. + */ + void refreshPlaylist(Uri url); + + /** + * Returns whether the tracked playlists describe a live stream. + * + * @return True if the content is live. False otherwise. + */ + boolean isLive(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java new file mode 100644 index 0000000000..be9f862644 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; |