diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index db7763c399..40b28487a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -488,7 +488,7 @@ public class SampleQueue implements TrackOutput { } @Override - public final void sampleMetadata( + public void sampleMetadata( long timeUs, @C.BufferFlags int flags, int size, diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index 8c51b954f4..41dc652e51 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -129,7 +129,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final ArrayList hlsSampleStreams; private final Map overridingDrmInitData; - private FormatAdjustingSampleQueue[] sampleQueues; + private HlsSampleQueue[] sampleQueues; private int[] sampleQueueTrackIds; private Set sampleQueueMappingDoneByType; private SparseIntArray sampleQueueIndicesByType; @@ -164,7 +164,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean tracksEnded; private long sampleOffsetUs; @Nullable private DrmInitData drmInitData; - private int sourceId; + @Nullable private HlsMediaChunk sourceChunk; /** * @param trackType The type of the track. One of the {@link C} {@code TRACK_TYPE_*} constants. @@ -209,7 +209,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueueTrackIds = new int[0]; sampleQueueMappingDoneByType = new HashSet<>(MAPPABLE_TYPES.size()); sampleQueueIndicesByType = new SparseIntArray(MAPPABLE_TYPES.size()); - sampleQueues = new FormatAdjustingSampleQueue[0]; + sampleQueues = new HlsSampleQueue[0]; sampleQueueIsAudioVideoFlags = new boolean[0]; sampleQueuesEnabledStates = new boolean[0]; mediaChunks = new ArrayList<>(); @@ -817,19 +817,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Performs initialization for a media chunk that's about to start loading. * - * @param mediaChunk The media chunk that's about to start loading. + * @param chunk The media chunk that's about to start loading. */ - private void initMediaChunkLoad(HlsMediaChunk mediaChunk) { - sourceId = mediaChunk.uid; - upstreamTrackFormat = mediaChunk.trackFormat; + private void initMediaChunkLoad(HlsMediaChunk chunk) { + sourceChunk = chunk; + upstreamTrackFormat = chunk.trackFormat; pendingResetPositionUs = C.TIME_UNSET; - mediaChunks.add(mediaChunk); + mediaChunks.add(chunk); - mediaChunk.init(this); - for (SampleQueue sampleQueue : sampleQueues) { - sampleQueue.sourceId(sourceId); + chunk.init(this); + for (HlsSampleQueue sampleQueue : sampleQueues) { + sampleQueue.setSourceChunk(chunk); } - if (mediaChunk.shouldSpliceIn) { + if (chunk.shouldSpliceIn) { for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.splice(); } @@ -906,18 +906,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int trackCount = sampleQueues.length; boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; - FormatAdjustingSampleQueue trackOutput = - new FormatAdjustingSampleQueue( - allocator, drmSessionManager, eventDispatcher, overridingDrmInitData); + HlsSampleQueue sampleQueue = + new HlsSampleQueue(allocator, drmSessionManager, eventDispatcher, overridingDrmInitData); if (isAudioVideo) { - trackOutput.setDrmInitData(drmInitData); + sampleQueue.setDrmInitData(drmInitData); } - trackOutput.setSampleOffsetUs(sampleOffsetUs); - trackOutput.sourceId(sourceId); - trackOutput.setUpstreamFormatChangeListener(this); + sampleQueue.setSampleOffsetUs(sampleOffsetUs); + if (sourceChunk != null) { + sampleQueue.setSourceChunk(sourceChunk); + } + sampleQueue.setUpstreamFormatChangeListener(this); sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1); sampleQueueTrackIds[trackCount] = id; - sampleQueues = Util.nullSafeArrayAppend(sampleQueues, trackOutput); + sampleQueues = Util.nullSafeArrayAppend(sampleQueues, sampleQueue); sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1); sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo; haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount]; @@ -928,7 +929,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; primarySampleQueueType = type; } sampleQueuesEnabledStates = Arrays.copyOf(sampleQueuesEnabledStates, trackCount + 1); - return trackOutput; + return sampleQueue; } @Override @@ -1341,12 +1342,40 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; return new DummyTrackOutput(); } - private static final class FormatAdjustingSampleQueue extends SampleQueue { + /** + * A {@link SampleQueue} that adds HLS specific functionality: + * + *
    + *
  • Detection of spurious discontinuities, by checking sample timestamps against the range + * expected for the currently loading chunk. + *
  • Stripping private timestamp metadata from {@link Format Formats} to avoid an excessive + * number of format switches in the queue. + *
  • Overriding of {@link Format#drmInitData}. + *
+ */ + private static final class HlsSampleQueue extends SampleQueue { + + /** + * The fraction of the chunk duration from which timestamps of samples loaded from within a + * chunk are allowed to deviate from the expected range. + */ + private static final double MAX_TIMESTAMP_DEVIATION_FRACTION = 0.5; + + /** + * A minimum tolerance for sample timestamps in microseconds. Timestamps of samples loaded from + * within a chunk are always allowed to deviate up to this amount from the expected range. + */ + private static final long MIN_TIMESTAMP_DEVIATION_TOLERANCE_US = 4_000_000; + + @Nullable private HlsMediaChunk sourceChunk; + private long sourceChunkLastSampleTimeUs; + private long minAllowedSampleTimeUs; + private long maxAllowedSampleTimeUs; private final Map overridingDrmInitData; @Nullable private DrmInitData drmInitData; - public FormatAdjustingSampleQueue( + private HlsSampleQueue( Allocator allocator, DrmSessionManager drmSessionManager, MediaSourceEventDispatcher eventDispatcher, @@ -1355,6 +1384,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.overridingDrmInitData = overridingDrmInitData; } + public void setSourceChunk(HlsMediaChunk chunk) { + sourceChunk = chunk; + sourceChunkLastSampleTimeUs = C.TIME_UNSET; + sourceId(chunk.uid); + + long allowedDeviationUs = + Math.max( + (long) ((chunk.endTimeUs - chunk.startTimeUs) * MAX_TIMESTAMP_DEVIATION_FRACTION), + MIN_TIMESTAMP_DEVIATION_TOLERANCE_US); + minAllowedSampleTimeUs = chunk.startTimeUs - allowedDeviationUs; + maxAllowedSampleTimeUs = chunk.endTimeUs + allowedDeviationUs; + } + public void setDrmInitData(@Nullable DrmInitData drmInitData) { this.drmInitData = drmInitData; invalidateUpstreamFormatAdjustment(); @@ -1415,13 +1457,31 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } return new Metadata(newMetadataEntries); } + + @Override + public void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + // TODO: Uncomment this to reject samples with unexpected timestamps. See + // https://github.com/google/ExoPlayer/issues/7030. + // if (timeUs < minAllowedSampleTimeUs || timeUs > maxAllowedSampleTimeUs) { + // Util.sneakyThrow( + // new UnexpectedSampleTimestampException( + // sourceChunk, sourceChunkLastSampleTimeUs, timeUs)); + // } + sourceChunkLastSampleTimeUs = timeUs; + super.sampleMetadata(timeUs, flags, size, offset, cryptoData); + } } private static class EmsgUnwrappingTrackOutput implements TrackOutput { private static final String TAG = "EmsgUnwrappingTrackOutput"; - // TODO(ibaker): Create a Formats util class with common constants like this. + // TODO: Create a Formats util class with common constants like this. private static final Format ID3_FORMAT = new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_ID3).build(); private static final Format EMSG_FORMAT = diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/UnexpectedSampleTimestampException.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/UnexpectedSampleTimestampException.java new file mode 100644 index 0000000000..50a11170a3 --- /dev/null +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/UnexpectedSampleTimestampException.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020 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 com.google.android.exoplayer2.source.hls; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.SampleQueue; +import com.google.android.exoplayer2.source.chunk.MediaChunk; +import java.io.IOException; + +/** + * Thrown when an attempt is made to write a sample to a {@link SampleQueue} whose timestamp is + * inconsistent with the chunk from which it originates. + */ +/* package */ final class UnexpectedSampleTimestampException extends IOException { + + /** The {@link MediaChunk} that contained the rejected sample. */ + public final MediaChunk mediaChunk; + + /** + * The timestamp of the last sample that was loaded from {@link #mediaChunk} and successfully + * written to the {@link SampleQueue}, in microseconds. {@link C#TIME_UNSET} if the first sample + * in the chunk was rejected. + */ + public final long lastAcceptedSampleTimeUs; + + /** The timestamp of the rejected sample, in microseconds. */ + public final long rejectedSampleTimeUs; + + /** + * Constructs an instance. + * + * @param mediaChunk The {@link MediaChunk} with the unexpected sample timestamp. + * @param lastAcceptedSampleTimeUs The timestamp of the last sample that was loaded from the chunk + * and successfully written to the {@link SampleQueue}, in microseconds. {@link C#TIME_UNSET} + * if the first sample in the chunk was rejected. + * @param rejectedSampleTimeUs The timestamp of the rejected sample, in microseconds. + */ + public UnexpectedSampleTimestampException( + MediaChunk mediaChunk, long lastAcceptedSampleTimeUs, long rejectedSampleTimeUs) { + super( + "Unexpected sample timestamp: " + + C.usToMs(rejectedSampleTimeUs) + + " in chunk [" + + mediaChunk.startTimeUs + + ", " + + mediaChunk.endTimeUs + + "]"); + this.mediaChunk = mediaChunk; + this.lastAcceptedSampleTimeUs = lastAcceptedSampleTimeUs; + this.rejectedSampleTimeUs = rejectedSampleTimeUs; + } +}