From d3bba3b0e6a70b1ad49c9445e43f5ca6dba792af Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Fri, 10 Sep 2021 13:20:04 -0700 Subject: [PATCH 1/2] Implements SeekParameters.*_SYNC variants for HLS The HLS implementation of `getAdjustedSeekPositionUs()` now completely supports `SeekParameters.CLOSEST_SYNC` and it's brotheran, assuming the HLS stream indicates segments all start with an IDR (that is EXT-X-INDEPENDENT-SEGMENTS is specified). This fixes issue #2882 and improves (but does not completely solve #8592 --- .../exoplayer2/source/hls/HlsChunkSource.java | 38 ++++ .../exoplayer2/source/hls/HlsMediaPeriod.java | 9 +- .../source/hls/HlsSampleStreamWrapper.java | 26 +++ .../source/hls/HlsChunkSourceTest.java | 197 ++++++++++++++++++ 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsChunkSourceTest.java diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index fa1dfb48ad..ee8fe4c2d0 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -26,6 +26,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; @@ -237,6 +238,43 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.isTimestampMaster = isTimestampMaster; } + /** + * Adjusts a seek position given the specified {@link SeekParameters}. The HLS Segment start times + * are used as the sync points iff the playlist declares {@link HlsMediaPlaylist#hasIndependentSegments} + * indicating each segment starts with an IDR. + * + * @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) { + long adjustedPositionUs = positionUs; + + int selectedIndex = trackSelection.getSelectedIndex(); + boolean haveTrackSelection = selectedIndex < playlistUrls.length && selectedIndex != C.INDEX_UNSET; + @Nullable HlsMediaPlaylist mediaPlaylist = null; + if (haveTrackSelection) { + mediaPlaylist = playlistTracker.getPlaylistSnapshot(playlistUrls[selectedIndex], /* isForPlayback= */ true); + } + + // Resolve to a segment boundary, current track is fine (all should be same). + // and, segments must start with sync (EXT-X-INDEPENDENT-SEGMENTS must be present) + if (mediaPlaylist != null && mediaPlaylist.hasIndependentSegments && !mediaPlaylist.segments.isEmpty()) { + long startOfPlaylistInPeriodUs = mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + long targetPositionInPlaylistUs = positionUs - startOfPlaylistInPeriodUs; + + int segIndex = Util.binarySearchFloor(mediaPlaylist.segments, targetPositionInPlaylistUs, true, true); + long firstSyncUs = mediaPlaylist.segments.get(segIndex).relativeStartTimeUs + startOfPlaylistInPeriodUs; + long secondSyncUs = firstSyncUs; + if (segIndex != mediaPlaylist.segments.size() - 1) { + secondSyncUs = mediaPlaylist.segments.get(segIndex + 1).relativeStartTimeUs + startOfPlaylistInPeriodUs; + } + adjustedPositionUs = seekParameters.resolveSeekPositionUs(positionUs, firstSyncUs, secondSyncUs); + } + + return adjustedPositionUs; + } + /** * Returns the publication state of the given chunk. * 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 6e4430923e..f064bc0436 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 @@ -417,7 +417,14 @@ public final class HlsMediaPeriod @Override public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { - return positionUs; + long seekTargetUs = positionUs; + for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) { + if (sampleStreamWrapper.isVideoSampleStream()) { + seekTargetUs = sampleStreamWrapper.getAdjustedSeekPositionUs(positionUs, seekParameters); + break; + } + } + return seekTargetUs; } // HlsSampleStreamWrapper.Callback implementation. 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 ca72c5516d..5b1db5bf1d 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 @@ -30,6 +30,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.drm.DrmInitData; import com.google.android.exoplayer2.drm.DrmSession; @@ -585,6 +586,31 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; && exclusionDurationMs != C.TIME_UNSET; } + /** + * Check if the primary sample stream is video, {@link C#TRACK_TYPE_VIDEO}. This + * HlsSampleStreamWrapper may managed audio and other streams muxed in the same + * container, but as long as it has a video stream this method returns true. + * + * @return true if there is a video SampleStream managed by this object. + */ + public boolean isVideoSampleStream() { + return primarySampleQueueType == C.TRACK_TYPE_VIDEO; + } + + + /** + * Adjusts a seek position given the specified {@link SeekParameters}. Method delegates to + * the associated {@link HlsChunkSource#getAdjustedSeekPositionUs(long, SeekParameters)}. + * + * @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); + } + + // SampleStream implementation. public boolean isReady(int sampleQueueIndex) { diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsChunkSourceTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsChunkSourceTest.java new file mode 100644 index 0000000000..74e5afe866 --- /dev/null +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/HlsChunkSourceTest.java @@ -0,0 +1,197 @@ +package com.google.android.exoplayer2.source.hls; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; + +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; +import com.google.android.exoplayer2.util.Util; + + +@RunWith(AndroidJUnit4.class) +public class HlsChunkSourceTest { + + public static final String TEST_PLAYLIST = "#EXTM3U\n" + + "#EXT-X-MEDIA-SEQUENCE:1606273114\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-I-FRAMES-ONLY\n" + + "#EXT-X-INDEPENDENT-SEGMENTS\n" + + "#EXT-X-MAP:URI=\"init-CCUR_iframe.tsv\"\n" + + "#EXT-X-PROGRAM-DATE-TIME:2020-11-25T02:58:34+00:00\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:52640@19965036\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:77832@20253992\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:168824@21007496\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:177848@21888840\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:69560@22496456\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXTINF:4,\n" + + "#EXT-X-BYTERANGE:41360@22830156\n" + + "1606272900-CCUR_iframe.tsv\n" + + "#EXT-X-ENDLIST\n" + + "\n"; + public static final Uri PLAYLIST_URI = Uri.parse("http://example.com/"); + + // simulate the playlist has reloaded since the period start. + private static final long PLAYLIST_START_PERIOD_OFFSET = 8_000_000L; + + private final HlsExtractorFactory mockExtractorFactory = HlsExtractorFactory.DEFAULT; + + @Mock + private HlsPlaylistTracker mockPlaylistTracker; + + @Mock + private HlsDataSourceFactory mockDataSourceFactory; + private HlsChunkSource testee; + private HlsMediaPlaylist playlist; + + @Before + public void setup() throws IOException { + // sadly, auto mock does not work, you get NoClassDefFoundError: com/android/dx/rop/type/Type +// MockitoAnnotations.initMocks(this); + mockPlaylistTracker = Mockito.mock(HlsPlaylistTracker.class); + mockDataSourceFactory = Mockito.mock(HlsDataSourceFactory.class); + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(TEST_PLAYLIST)); + playlist = (HlsMediaPlaylist) new HlsPlaylistParser().parse(PLAYLIST_URI, inputStream); + + when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean())).thenReturn(playlist); + + testee = new HlsChunkSource( + mockExtractorFactory, + mockPlaylistTracker, + new Uri[] {PLAYLIST_URI}, + new Format[] { ExoPlayerTestRunner.VIDEO_FORMAT }, + mockDataSourceFactory, + null, + new TimestampAdjusterProvider(), + null); + + when(mockPlaylistTracker.isSnapshotValid(eq(PLAYLIST_URI))).thenReturn(true); + + // mock a couple of target duration (4s) updates to the playlist since period starts + when(mockPlaylistTracker.getInitialStartTimeUs()).thenReturn(playlist.startTimeUs - PLAYLIST_START_PERIOD_OFFSET); + } + + @Test + public void getAdjustedSeekPositionUs_PreviousSync() { + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.PREVIOUS_SYNC); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(16_000_000); + } + + @Test + public void getAdjustedSeekPositionUs_NextSync() { + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.NEXT_SYNC); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(20_000_000); + } + + @Test + public void getAdjustedSeekPositionUs_NextSyncAtEnd() { + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(24_000_000), SeekParameters.NEXT_SYNC); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(24_000_000); + } + + @Test + public void getAdjustedSeekPositionUs_ClosestSync() { + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.CLOSEST_SYNC); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(16_000_000); + + adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(19_000_000), SeekParameters.CLOSEST_SYNC); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(20_000_000); + } + + @Test + public void getAdjustedSeekPositionUs_Exact() { + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.EXACT); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(17_000_000); + } + + @Test + public void getAdjustedSeekPositionUs_NoIndependedSegments() { + HlsMediaPlaylist mockPlaylist = getMockEmptyPlaylist(false); + when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean())).thenReturn(mockPlaylist); + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(100_000_000), SeekParameters.EXACT); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(100_000_000); + } + + + @Test + public void getAdjustedSeekPositionUs_EmptyPlaylist() { + HlsMediaPlaylist mockPlaylist = getMockEmptyPlaylist(true); + when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean())).thenReturn(mockPlaylist); + long adjusted = testee.getAdjustedSeekPositionUs(playlistTimeToPeriodTimeUs(100_000_000), SeekParameters.EXACT); + assertThat(periodTimeToPlaylistTime(adjusted)).isEqualTo(100_000_000); + } + + + + /** + * Convert playlist start relative time to {@link MediaPeriod} relative time. + * + * It is easier to express test case values relative to the playlist. + * + * @param playlistTimeUs - playlist time (first segment start is time 0) + * @return period time, offset of the playlist update (the Window) from start of period + */ + private long playlistTimeToPeriodTimeUs(long playlistTimeUs) { + return playlistTimeUs + PLAYLIST_START_PERIOD_OFFSET; + } + + private long periodTimeToPlaylistTime(long periodTimeUs) { + return periodTimeUs - PLAYLIST_START_PERIOD_OFFSET; + } + + + private HlsMediaPlaylist getMockEmptyPlaylist(boolean hasIndependentSegments) { + return new HlsMediaPlaylist( + HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN, + PLAYLIST_URI.toString(), + Collections.emptyList(), + 0, + false, + 0, + false, + 0, + 0, + 8, + 6, + 2, + hasIndependentSegments, + false, + true, + null, + Collections.emptyList(), + Collections.emptyList(), + new HlsMediaPlaylist.ServerControl(0, true, 0, 0, true), + Collections.emptyMap() + ); + } +} From 530dd3f733a13190a8f75fd133bcb4aa24b06aaf Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Fri, 5 Nov 2021 13:08:20 -0700 Subject: [PATCH 2/2] Correct comment on track selection during seek The comment "all should be same" is not correct, it is extremely likely they will be the same but not assured. In either case the seek position will work, it just may not land exactly on a sync point in every variant. --- .../android/exoplayer2/source/hls/HlsChunkSource.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index ee8fe4c2d0..64f700e035 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -257,8 +257,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; mediaPlaylist = playlistTracker.getPlaylistSnapshot(playlistUrls[selectedIndex], /* isForPlayback= */ true); } - // Resolve to a segment boundary, current track is fine (all should be same). - // and, segments must start with sync (EXT-X-INDEPENDENT-SEGMENTS must be present) + // If segments must start with sync (EXT-X-INDEPENDENT-SEGMENTS is set) and the playlist is not empty + // resolve using the nearest segment start and the next segment (if any) start as the first and second + // sync points. + // Note, the position returned is normalized to the period, so it will work if a track selection changes + // variants before the seek is executed. It is possible it may not land exactly on a segment sync point in the rare + // case the segment boundaries do not align across variants. + // if (mediaPlaylist != null && mediaPlaylist.hasIndependentSegments && !mediaPlaylist.segments.isEmpty()) { long startOfPlaylistInPeriodUs = mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); long targetPositionInPlaylistUs = positionUs - startOfPlaylistInPeriodUs;