diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index 5a384efcdf..73a46b68ba 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.offline.StreamKey; import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; @@ -68,6 +69,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper private TrackGroupArray trackGroups; private HlsSampleStreamWrapper[] sampleStreamWrappers; private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; + private int[] selectedVariantIndices; private SequenceableLoader compositeSequenceableLoader; private boolean notifiedReadingStarted; @@ -112,6 +114,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper timestampAdjusterProvider = new TimestampAdjusterProvider(); sampleStreamWrappers = new HlsSampleStreamWrapper[0]; enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; + selectedVariantIndices = new int[0]; eventDispatcher.mediaPeriodCreated(); } @@ -143,6 +146,77 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper return trackGroups; } + @Override + public List getStreamKeys(List trackSelections) { + // See HlsMasterPlaylist.copy for interpretation of StreamKeys. + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + int audioWrapperOffset = hasVariants ? 1 : 0; + int subtitleWrapperOffset = audioWrapperOffset + masterPlaylist.audios.size(); + + TrackGroupArray mainWrapperTrackGroups; + int mainWrapperPrimaryGroupIndex; + if (hasVariants) { + HlsSampleStreamWrapper mainWrapper = sampleStreamWrappers[0]; + mainWrapperTrackGroups = mainWrapper.getTrackGroups(); + mainWrapperPrimaryGroupIndex = mainWrapper.getPrimaryTrackGroupIndex(); + } else { + mainWrapperTrackGroups = TrackGroupArray.EMPTY; + mainWrapperPrimaryGroupIndex = 0; + } + + List 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 = selectedVariantIndices[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(); + if (wrapperTrackGroups.indexOf(trackSelectionGroup) != C.INDEX_UNSET) { + if (i < subtitleWrapperOffset) { + streamKeys.add( + new StreamKey(HlsMasterPlaylist.GROUP_INDEX_AUDIO, i - audioWrapperOffset)); + } else { + streamKeys.add( + new StreamKey(HlsMasterPlaylist.GROUP_INDEX_SUBTITLE, i - subtitleWrapperOffset)); + } + 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 = selectedVariantIndices[0]; + int lowestBitrate = masterPlaylist.variants.get(selectedVariantIndices[0]).format.bitrate; + for (int i = 1; i < selectedVariantIndices.length; i++) { + int variantBitrate = masterPlaylist.variants.get(selectedVariantIndices[i]).format.bitrate; + if (variantBitrate < lowestBitrate) { + lowestBitrate = variantBitrate; + lowestBitrateIndex = selectedVariantIndices[i]; + } + } + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, lowestBitrateIndex)); + } + return streamKeys; + } + @Override public long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, SampleStream[] streams, boolean[] streamResetFlags, long positionUs) { @@ -424,44 +498,64 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper */ private void buildAndPrepareMainSampleStreamWrapper( HlsMasterPlaylist masterPlaylist, long positionUs) { - List selectedVariants = new ArrayList<>(masterPlaylist.variants); - ArrayList definiteVideoVariants = new ArrayList<>(); - ArrayList definiteAudioOnlyVariants = new ArrayList<>(); - for (int i = 0; i < selectedVariants.size(); i++) { - HlsUrl variant = selectedVariants.get(i); + int[] variantTypes = new int[masterPlaylist.variants.size()]; + int videoVariantCount = 0; + int audioVariantCount = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + HlsUrl variant = masterPlaylist.variants.get(i); Format format = variant.format; if (format.height > 0 || Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_VIDEO) != null) { - definiteVideoVariants.add(variant); + variantTypes[i] = C.TRACK_TYPE_VIDEO; + videoVariantCount++; } else if (Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_AUDIO) != null) { - definiteAudioOnlyVariants.add(variant); + variantTypes[i] = C.TRACK_TYPE_AUDIO; + audioVariantCount++; + } else { + variantTypes[i] = C.TRACK_TYPE_UNKNOWN; } } - if (!definiteVideoVariants.isEmpty()) { + 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. - selectedVariants = definiteVideoVariants; - } else if (definiteAudioOnlyVariants.size() < selectedVariants.size()) { + 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. - selectedVariants.removeAll(definiteAudioOnlyVariants); - } else { - // Leave the enabled variants unchanged. They're likely either all video or all audio. + useNonAudioVariantsOnly = true; + selectedVariantsCount = variantTypes.length - audioVariantCount; } - Assertions.checkArgument(!selectedVariants.isEmpty()); - HlsUrl[] variants = selectedVariants.toArray(new HlsUrl[0]); - String codecs = variants[0].format.codecs; - HlsSampleStreamWrapper sampleStreamWrapper = buildSampleStreamWrapper(C.TRACK_TYPE_DEFAULT, - variants, masterPlaylist.muxedAudioFormat, masterPlaylist.muxedCaptionFormats, positionUs); + HlsUrl[] selectedVariants = new HlsUrl[selectedVariantsCount]; + 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)) { + selectedVariants[outIndex] = masterPlaylist.variants.get(i); + selectedVariantIndices[outIndex++] = i; + } + } + String codecs = selectedVariants[0].format.codecs; + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_DEFAULT, + selectedVariants, + masterPlaylist.muxedAudioFormat, + masterPlaylist.muxedCaptionFormats, + positionUs); sampleStreamWrappers[0] = sampleStreamWrapper; 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 muxedTrackGroups = new ArrayList<>(); if (variantsContainVideoCodecs) { - Format[] videoFormats = new Format[selectedVariants.size()]; + Format[] videoFormats = new Format[selectedVariantsCount]; for (int i = 0; i < videoFormats.length; i++) { - videoFormats[i] = deriveVideoFormat(variants[i].format); + videoFormats[i] = deriveVideoFormat(selectedVariants[i].format); } muxedTrackGroups.add(new TrackGroup(videoFormats)); @@ -470,7 +564,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper muxedTrackGroups.add( new TrackGroup( deriveAudioFormat( - variants[0].format, + selectedVariants[0].format, masterPlaylist.muxedAudioFormat, /* isPrimaryTrackInVariant= */ false))); } @@ -482,9 +576,9 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper } } else if (variantsContainAudioCodecs) { // Variants only contain audio. - Format[] audioFormats = new Format[selectedVariants.size()]; + Format[] audioFormats = new Format[selectedVariantsCount]; for (int i = 0; i < audioFormats.length; i++) { - Format variantFormat = variants[i].format; + Format variantFormat = selectedVariants[i].format; audioFormats[i] = deriveAudioFormat( variantFormat, 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 39598c4cd8..4fd27ba2a0 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 @@ -214,6 +214,10 @@ import java.util.List; return trackGroups; } + public int getPrimaryTrackGroupIndex() { + return primaryTrackGroupIndex; + } + public int bindSampleQueueToSampleStream(int trackGroupIndex) { int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; if (sampleQueueIndex == C.INDEX_UNSET) { diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java new file mode 100644 index 0000000000..599e099b8c --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriodTest.java @@ -0,0 +1,184 @@ +/* + * 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 com.google.android.exoplayer2.source.hls; + +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts; +import com.google.android.exoplayer2.testutil.MediaPeriodAsserts.FilterableManifestMediaPeriodFactory; +import com.google.android.exoplayer2.testutil.RobolectricUtil; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit test for {@link HlsMediaPeriod}. */ +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {RobolectricUtil.CustomLooper.class, RobolectricUtil.CustomMessageQueue.class}) +public final class HlsMediaPeriodTest { + + @Test + public void getSteamKeys_isCompatibleWithhHlsMasterPlaylistFilter() { + HlsMasterPlaylist testMasterPlaylist = + createMasterPlaylist( + /* variants= */ Arrays.asList( + createAudioOnlyVariantHlsUrl(/* bitrate= */ 10000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 200000), + createAudioOnlyVariantHlsUrl(/* bitrate= */ 300000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 400000), + createMuxedVideoAudioVariantHlsUrl(/* bitrate= */ 600000)), + /* audios= */ Arrays.asList( + createAudioHlsUrl(/* language= */ "spa"), + createAudioHlsUrl(/* language= */ "ger"), + createAudioHlsUrl(/* language= */ "tur")), + /* subtitles= */ Arrays.asList( + createSubtitleHlsUrl(/* language= */ "spa"), + createSubtitleHlsUrl(/* language= */ "ger"), + createSubtitleHlsUrl(/* language= */ "tur")), + /* muxedAudioFormat= */ createAudioFormat("eng"), + /* muxedCaptionFormats= */ Arrays.asList( + createSubtitleFormat("eng"), createSubtitleFormat("gsw"))); + FilterableManifestMediaPeriodFactory mediaPeriodFactory = + (playlist, periodIndex) -> { + HlsDataSourceFactory mockDataSourceFactory = mock(HlsDataSourceFactory.class); + when(mockDataSourceFactory.createDataSource(anyInt())).thenReturn(mock(DataSource.class)); + HlsPlaylistTracker mockPlaylistTracker = mock(HlsPlaylistTracker.class); + when(mockPlaylistTracker.getMasterPlaylist()).thenReturn((HlsMasterPlaylist) playlist); + return new HlsMediaPeriod( + mock(HlsExtractorFactory.class), + mockPlaylistTracker, + mockDataSourceFactory, + mock(TransferListener.class), + mock(LoadErrorHandlingPolicy.class), + new EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + /* mediaPeriodId= */ new MediaPeriodId(/* periodUid= */ new Object()), + /* mediaTimeOffsetMs= */ 0), + mock(Allocator.class), + mock(CompositeSequenceableLoaderFactory.class), + /* allowChunklessPreparation =*/ true); + }; + + MediaPeriodAsserts.assertGetStreamKeysAndManifestFilterIntegration( + mediaPeriodFactory, testMasterPlaylist); + } + + private static HlsMasterPlaylist createMasterPlaylist( + List variants, + List audios, + List subtitles, + Format muxedAudioFormat, + List muxedCaptionFormats) { + return new HlsMasterPlaylist( + "http://baseUri", + /* tags= */ Collections.emptyList(), + variants, + audios, + subtitles, + muxedAudioFormat, + muxedCaptionFormats, + /* hasIndependentSegments= */ true, + /* variableDefinitions= */ Collections.emptyMap()); + } + + private static HlsUrl createMuxedVideoAudioVariantHlsUrl(int bitrate) { + return new HlsUrl( + "http://url", + Format.createVideoContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ "avc1.100.41,mp4a.40.2", + bitrate, + /* width= */ Format.NO_VALUE, + /* height= */ Format.NO_VALUE, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0)); + } + + private static HlsUrl createAudioOnlyVariantHlsUrl(int bitrate) { + return new HlsUrl( + "http://url", + Format.createVideoContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ "mp4a.40.2", + bitrate, + /* width= */ Format.NO_VALUE, + /* height= */ Format.NO_VALUE, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0)); + } + + private static HlsUrl createAudioHlsUrl(String language) { + return new HlsUrl("http://url", createAudioFormat(language)); + } + + private static HlsUrl createSubtitleHlsUrl(String language) { + return new HlsUrl("http://url", createSubtitleFormat(language)); + } + + private static Format createAudioFormat(String language) { + return Format.createAudioContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + MimeTypes.getMediaMimeType("mp4a.40.2"), + /* codecs= */ "mp4a.40.2", + /* bitrate= */ Format.NO_VALUE, + /* channelCount= */ Format.NO_VALUE, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* selectionFlags= */ 0, + language); + } + + private static Format createSubtitleFormat(String language) { + return Format.createTextContainerFormat( + /* id= */ null, + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ MimeTypes.TEXT_VTT, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + language); + } +}