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
This commit is contained in:
parent
03ff5b6618
commit
d3bba3b0e6
@ -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.
|
||||
*
|
||||
|
@ -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.
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user