/*
* 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 androidx.media3.exoplayer.source;
import static java.lang.Math.max;
import android.util.Pair;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.Timeline.Window;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.upstream.Allocator;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
/**
* A {@link MediaSource} that masks the {@link Timeline} with a placeholder until the actual media
* structure is known.
*/
@UnstableApi
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;
private boolean hasStartedPreparing;
private boolean isPrepared;
private boolean hasRealTimeline;
/**
* 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 && mediaSource.isSingleWindow();
window = new Timeline.Window();
period = new Timeline.Period();
@Nullable Timeline initialTimeline = mediaSource.getInitialTimeline();
if (initialTimeline != null) {
timeline =
MaskingTimeline.createWithRealTimeline(
initialTimeline, /* firstWindowUid= */ null, /* firstPeriodUid= */ null);
hasRealTimeline = true;
} else {
timeline = MaskingTimeline.createWithPlaceholderTimeline(mediaSource.getMediaItem());
}
}
/** 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);
}
}
@Override
public MediaItem getMediaItem() {
return mediaSource.getMediaItem();
}
@Override
@SuppressWarnings("MissingSuperCall")
public void maybeThrowSourceInfoRefreshError() {
// 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(id, allocator, startPositionUs);
mediaPeriod.setMediaSource(mediaSource);
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;
if (!hasStartedPreparing) {
hasStartedPreparing = true;
prepareChildSource(/* id= */ null, mediaSource);
}
}
return mediaPeriod;
}
@Override
public void releasePeriod(MediaPeriod mediaPeriod) {
((MaskingMediaPeriod) mediaPeriod).releasePeriod();
if (mediaPeriod == unpreparedMaskingMediaPeriod) {
unpreparedMaskingMediaPeriod = null;
}
}
@Override
public void releaseSourceInternal() {
isPrepared = false;
hasStartedPreparing = false;
super.releaseSourceInternal();
}
@Override
protected void onChildSourceInfoRefreshed(
Void id, MediaSource mediaSource, Timeline newTimeline) {
@Nullable MediaPeriodId idForMaskingPeriodPreparation = null;
if (isPrepared) {
timeline = timeline.cloneWithUpdatedTimeline(newTimeline);
if (unpreparedMaskingMediaPeriod != null) {
// Reset override in case the duration changed and we need to update our override.
setPreparePositionOverrideToUnpreparedMaskingPeriod(
unpreparedMaskingMediaPeriod.getPreparePositionOverrideUs());
}
} else if (newTimeline.isEmpty()) {
timeline =
hasRealTimeline
? timeline.cloneWithUpdatedTimeline(newTimeline)
: MaskingTimeline.createWithRealTimeline(
newTimeline,
Window.SINGLE_WINDOW_UID,
MaskingTimeline.MASKING_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 PlaceholderTimeline 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();
Object windowUid = window.uid;
if (unpreparedMaskingMediaPeriod != null) {
long periodPreparePositionUs = unpreparedMaskingMediaPeriod.getPreparePositionUs();
timeline.getPeriodByUid(unpreparedMaskingMediaPeriod.id.periodUid, period);
long windowPreparePositionUs = period.getPositionInWindowUs() + periodPreparePositionUs;
long oldWindowDefaultPositionUs =
timeline.getWindow(/* windowIndex= */ 0, window).getDefaultPositionUs();
if (windowPreparePositionUs != oldWindowDefaultPositionUs) {
windowStartPositionUs = windowPreparePositionUs;
}
}
Pair<Object, Long> periodUidAndPositionUs =
newTimeline.getPeriodPositionUs(
window, period, /* windowIndex= */ 0, windowStartPositionUs);
Object periodUid = periodUidAndPositionUs.first;
long periodPositionUs = periodUidAndPositionUs.second;
timeline =
hasRealTimeline
? timeline.cloneWithUpdatedTimeline(newTimeline)
: MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid);
if (unpreparedMaskingMediaPeriod != null) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
setPreparePositionOverrideToUnpreparedMaskingPeriod(periodPositionUs);
idForMaskingPeriodPreparation =
maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid));
}
}
hasRealTimeline = true;
isPrepared = true;
refreshSourceInfo(this.timeline);
if (idForMaskingPeriodPreparation != null) {
Assertions.checkNotNull(unpreparedMaskingMediaPeriod)
.createPeriod(idForMaskingPeriodPreparation);
}
}
@Override
@Nullable
protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId(
Void id, MediaPeriodId mediaPeriodId) {
return mediaPeriodId.copyWithPeriodUid(getExternalPeriodUid(mediaPeriodId.periodUid));
}
private Object getInternalPeriodUid(Object externalPeriodUid) {
return timeline.replacedInternalPeriodUid != null
&& externalPeriodUid.equals(MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID)
? timeline.replacedInternalPeriodUid
: externalPeriodUid;
}
private Object getExternalPeriodUid(Object internalPeriodUid) {
return timeline.replacedInternalPeriodUid != null
&& timeline.replacedInternalPeriodUid.equals(internalPeriodUid)
? MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID
: internalPeriodUid;
}
@RequiresNonNull("unpreparedMaskingMediaPeriod")
private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePositionOverrideUs) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
int maskingPeriodIndex = timeline.getIndexOfPeriod(maskingPeriod.id.periodUid);
if (maskingPeriodIndex == C.INDEX_UNSET) {
// The new timeline doesn't contain this period anymore. This can happen if the media source
// has multiple periods and removed the first period with a timeline update. Ignore the
// update, as the non-existing period will be released anyway as soon as the player receives
// this new timeline.
return;
}
long periodDurationUs = timeline.getPeriod(maskingPeriodIndex, period).durationUs;
if (periodDurationUs != C.TIME_UNSET) {
// Ensure the overridden position doesn't exceed the period duration.
if (preparePositionOverrideUs >= periodDurationUs) {
preparePositionOverrideUs = max(0, periodDurationUs - 1);
}
}
maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs);
}
/**
* Timeline used as placeholder for an unprepared media source. After preparation, a
* MaskingTimeline is used to keep the originally assigned masking period ID.
*/
private static final class MaskingTimeline extends ForwardingTimeline {
public static final Object MASKING_EXTERNAL_PERIOD_UID = new Object();
@Nullable private final Object replacedInternalWindowUid;
@Nullable private final Object replacedInternalPeriodUid;
/**
* Returns an instance with a placeholder timeline using the provided {@link MediaItem}.
*
* @param mediaItem A {@link MediaItem}.
*/
public static MaskingTimeline createWithPlaceholderTimeline(MediaItem mediaItem) {
return new MaskingTimeline(
new PlaceholderTimeline(mediaItem),
Window.SINGLE_WINDOW_UID,
MASKING_EXTERNAL_PERIOD_UID);
}
/**
* Returns an instance with a real timeline, replacing the provided period ID with the already
* assigned masking 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 #MASKING_EXTERNAL_PERIOD_UID}.
*/
public static MaskingTimeline createWithRealTimeline(
Timeline timeline, @Nullable Object firstWindowUid, @Nullable Object firstPeriodUid) {
return new MaskingTimeline(timeline, firstWindowUid, firstPeriodUid);
}
private MaskingTimeline(
Timeline timeline,
@Nullable Object replacedInternalWindowUid,
@Nullable 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) && setIds) {
period.uid = MASKING_EXTERNAL_PERIOD_UID;
}
return period;
}
@Override
public int getIndexOfPeriod(Object uid) {
return timeline.getIndexOfPeriod(
MASKING_EXTERNAL_PERIOD_UID.equals(uid) && replacedInternalPeriodUid != null
? replacedInternalPeriodUid
: uid);
}
@Override
public Object getUidOfPeriod(int periodIndex) {
Object uid = timeline.getUidOfPeriod(periodIndex);
return Util.areEqual(uid, replacedInternalPeriodUid) ? MASKING_EXTERNAL_PERIOD_UID : uid;
}
}
/** A timeline with one dynamic window with a period of indeterminate duration. */
@VisibleForTesting
public static final class PlaceholderTimeline extends Timeline {
private final MediaItem mediaItem;
/** Creates a new instance with the given media item. */
public PlaceholderTimeline(MediaItem mediaItem) {
this.mediaItem = mediaItem;
}
@Override
public int getWindowCount() {
return 1;
}
@Override
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
window.set(
Window.SINGLE_WINDOW_UID,
mediaItem,
/* manifest= */ null,
/* presentationStartTimeMs= */ C.TIME_UNSET,
/* windowStartTimeMs= */ C.TIME_UNSET,
/* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET,
/* isSeekable= */ false,
// Dynamic window to indicate pending timeline updates.
/* isDynamic= */ true,
/* liveConfiguration= */ null,
/* defaultPositionUs= */ 0,
/* durationUs= */ C.TIME_UNSET,
/* firstPeriodIndex= */ 0,
/* lastPeriodIndex= */ 0,
/* positionInFirstPeriodUs= */ 0);
window.isPlaceholder = true;
return window;
}
@Override
public int getPeriodCount() {
return 1;
}
@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
period.set(
/* id= */ setIds ? 0 : null,
/* uid= */ setIds ? MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID : null,
/* windowIndex= */ 0,
/* durationUs = */ C.TIME_UNSET,
/* positionInWindowUs= */ 0,
/* adPlaybackState= */ AdPlaybackState.NONE,
/* isPlaceholder= */ true);
return period;
}
@Override
public int getIndexOfPeriod(Object uid) {
return uid == MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID ? 0 : C.INDEX_UNSET;
}
@Override
public Object getUidOfPeriod(int periodIndex) {
return MaskingTimeline.MASKING_EXTERNAL_PERIOD_UID;
}
}
}