diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index a8c1ab15f9..a44d272bd3 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -278,10 +278,9 @@ import java.util.Locale; DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength, null); out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex], - trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence, - segment.discontinuitySequenceNumber, isTimestampMaster, timestampAdjuster, previous, - encryptionKey, encryptionIv); + trackSelection.getSelectionReason(), trackSelection.getSelectionData(), startTimeUs, + startTimeUs + segment.durationUs, chunkMediaSequence, segment.discontinuitySequenceNumber, + isTimestampMaster, timestampAdjuster, previous, encryptionKey, encryptionIv); } /** diff --git a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index 0edf39ccba..c125e67c60 100644 --- a/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -16,6 +16,8 @@ package com.google.android.exoplayer2.source.hls; import android.text.TextUtils; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; import com.google.android.exoplayer2.extractor.Extractor; import com.google.android.exoplayer2.extractor.ExtractorInput; @@ -26,11 +28,15 @@ import com.google.android.exoplayer2.extractor.ts.Ac3Extractor; import com.google.android.exoplayer2.extractor.ts.AdtsExtractor; import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; import com.google.android.exoplayer2.extractor.ts.TsExtractor; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import com.google.android.exoplayer2.metadata.id3.PrivFrame; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.ParsableByteArray; import com.google.android.exoplayer2.util.Util; import java.io.IOException; import java.util.concurrent.atomic.AtomicInteger; @@ -42,6 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger; private static final AtomicInteger UID_SOURCE = new AtomicInteger(); + private static final String PRIV_TIMESTAMP_FRAME_OWNER = + "com.apple.streaming.transportStreamTimestamp"; + private static final String AAC_FILE_EXTENSION = ".aac"; private static final String AC3_FILE_EXTENSION = ".ac3"; private static final String EC3_FILE_EXTENSION = ".ec3"; @@ -71,6 +80,11 @@ import java.util.concurrent.atomic.AtomicInteger; private final boolean isMasterTimestampSource; private final TimestampAdjuster timestampAdjuster; private final HlsMediaChunk previousChunk; + private final String lastPathSegment; + + private final boolean isPackedAudio; + private final Id3Decoder id3Decoder; + private final ParsableByteArray id3Data; private Extractor extractor; private int initSegmentBytesLoaded; @@ -113,6 +127,19 @@ import java.util.concurrent.atomic.AtomicInteger; this.previousChunk = previousChunk; // Note: this.dataSource and dataSource may be different. this.isEncrypted = this.dataSource instanceof Aes128DataSource; + lastPathSegment = dataSpec.uri.getLastPathSegment(); + isPackedAudio = lastPathSegment.endsWith(AAC_FILE_EXTENSION) + || lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION) + || lastPathSegment.endsWith(MP3_FILE_EXTENSION); + if (isPackedAudio) { + id3Decoder = previousChunk != null ? previousChunk.id3Decoder : new Id3Decoder(); + id3Data = previousChunk != null ? previousChunk.id3Data + : new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + } else { + id3Decoder = null; + id3Data = null; + } initDataSource = dataSource; adjustedEndTimeUs = endTimeUs; uid = UID_SOURCE.getAndIncrement(); @@ -167,8 +194,9 @@ import java.util.concurrent.atomic.AtomicInteger; @Override public void load() throws IOException, InterruptedException { - if (extractor == null) { - extractor = buildExtractor(); + if (extractor == null && !isPackedAudio) { + // See HLS spec, version 20, Section 3.4 for more information on packed audio extraction. + extractor = buildExtractorByExtension(); } maybeLoadInitData(); if (!loadCanceled) { @@ -176,27 +204,152 @@ import java.util.concurrent.atomic.AtomicInteger; } } - // Private methods. + // Internal loading methods. - private Extractor buildExtractor() { + private void maybeLoadInitData() throws IOException, InterruptedException { + if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted + || initDataSpec == null) { + return; + } + DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); + try { + ExtractorInput input = new DefaultExtractorInput(initDataSource, + initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec)); + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, null); + } + } finally { + initSegmentBytesLoaded = (int) (input.getPosition() - initDataSpec.absoluteStreamPosition); + } + } finally { + Util.closeQuietly(dataSource); + } + initLoadCompleted = true; + } + + private void loadMedia() 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 (isEncrypted) { + loadDataSpec = dataSpec; + skipLoadedBytes = bytesLoaded != 0; + } else { + loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded); + skipLoadedBytes = false; + } + if (!isMasterTimestampSource) { + timestampAdjuster.waitUntilInitialized(); + } + try { + ExtractorInput input = new DefaultExtractorInput(dataSource, + loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + if (extractor == null) { + // Media segment format is packed audio. + long id3Timestamp = peekId3PrivTimestamp(input); + if (id3Timestamp == C.TIME_UNSET) { + throw new ParserException("ID3 PRIV timestamp missing."); + } + extractor = buildPackedAudioExtractor(timestampAdjuster.adjustTsTimestamp(id3Timestamp)); + } + if (skipLoadedBytes) { + input.skipFully(bytesLoaded); + } + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, null); + } + long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs(); + if (adjustedEndTimeUs != Long.MIN_VALUE) { + this.adjustedEndTimeUs = adjustedEndTimeUs; + } + } finally { + bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); + } + } finally { + Util.closeQuietly(dataSource); + } + loadCompleted = true; + } + + /** + * 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(); + if (!input.peekFully(id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH, true)) { + return C.TIME_UNSET; + } + id3Data.reset(Id3Decoder.ID3_HEADER_LENGTH); + int id = id3Data.readUnsignedInt24(); + if (id != Id3Decoder.ID3_TAG) { + return C.TIME_UNSET; + } + id3Data.skipBytes(3); // version(2), flags(1). + int id3Size = id3Data.readSynchSafeInt(); + int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH; + if (requiredCapacity > id3Data.capacity()) { + byte[] data = id3Data.data; + id3Data.reset(requiredCapacity); + System.arraycopy(data, 0, id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } + if (!input.peekFully(id3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size, true)) { + return C.TIME_UNSET; + } + Metadata metadata = id3Decoder.decode(id3Data.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, id3Data.data, 0, 8 /* timestamp size */); + id3Data.reset(8); + return id3Data.readLong(); + } + } + } + return C.TIME_UNSET; + } + + // Internal factory methods. + + /** + * If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in + * order to decrypt the loaded data. Else returns the original. + */ + private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey, + byte[] encryptionIv) { + if (encryptionKey == null || encryptionIv == null) { + return dataSource; + } + return new Aes128DataSource(dataSource, encryptionKey, encryptionIv); + } + + private Extractor buildExtractorByExtension() { // Set the extractor that will read the chunk. Extractor extractor; boolean needNewExtractor = previousChunk == null || previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber || trackFormat != previousChunk.trackFormat; boolean usingNewExtractor = true; - String lastPathSegment = dataSpec.uri.getLastPathSegment(); - if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { - // TODO: Inject a timestamp adjuster and use it along with ID3 PRIV tag values with owner - // identifier com.apple.streaming.transportStreamTimestamp. This may also apply to the MP3 - // case below. - extractor = new AdtsExtractor(startTimeUs); - } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) - || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { - extractor = new Ac3Extractor(startTimeUs); - } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { - extractor = new Mp3Extractor(startTimeUs); - } else if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) + if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster); } else if (!needNewExtractor) { @@ -231,80 +384,20 @@ import java.util.concurrent.atomic.AtomicInteger; return extractor; } - private void maybeLoadInitData() throws IOException, InterruptedException { - if ((previousChunk != null && previousChunk.extractor == extractor) - || initLoadCompleted || initDataSpec == null) { - return; - } - DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded); - try { - ExtractorInput input = new DefaultExtractorInput(initDataSource, - initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec)); - try { - int result = Extractor.RESULT_CONTINUE; - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, null); - } - } finally { - initSegmentBytesLoaded += (int) (input.getPosition() - dataSpec.absoluteStreamPosition); - } - } finally { - Util.closeQuietly(dataSource); - } - initLoadCompleted = true; - } - - private void loadMedia() 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 (isEncrypted) { - loadDataSpec = dataSpec; - skipLoadedBytes = bytesLoaded != 0; + private Extractor buildPackedAudioExtractor(long startTimeUs) { + Extractor extractor; + if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { + extractor = new AdtsExtractor(startTimeUs); + } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { + extractor = new Ac3Extractor(startTimeUs); + } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { + extractor = new Mp3Extractor(startTimeUs); } else { - loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded); - skipLoadedBytes = false; + throw new IllegalArgumentException("Unkown extension for audio file: " + lastPathSegment); } - try { - ExtractorInput input = new DefaultExtractorInput(dataSource, - loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); - if (skipLoadedBytes) { - input.skipFully(bytesLoaded); - } - try { - int result = Extractor.RESULT_CONTINUE; - if (!isMasterTimestampSource && timestampAdjuster != null) { - timestampAdjuster.waitUntilInitialized(); - } - while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { - result = extractor.read(input, null); - } - long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs(); - if (adjustedEndTimeUs != Long.MIN_VALUE) { - this.adjustedEndTimeUs = adjustedEndTimeUs; - } - } finally { - bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); - } - } finally { - Util.closeQuietly(dataSource); - } - loadCompleted = true; - } - - /** - * If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in - * order to decrypt the loaded data. Else returns the original. - */ - private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey, - byte[] encryptionIv) { - if (encryptionKey == null || encryptionIv == null) { - return dataSource; - } - return new Aes128DataSource(dataSource, encryptionKey, encryptionIv); + extractor.init(extractorOutput); + return extractor; } }