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 androidx.annotation.VisibleForTesting;
|
||||||
import com.google.android.exoplayer2.C;
|
import com.google.android.exoplayer2.C;
|
||||||
import com.google.android.exoplayer2.Format;
|
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.BehindLiveWindowException;
|
||||||
import com.google.android.exoplayer2.source.TrackGroup;
|
import com.google.android.exoplayer2.source.TrackGroup;
|
||||||
import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator;
|
import com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator;
|
||||||
@ -237,6 +238,43 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
|||||||
this.isTimestampMaster = isTimestampMaster;
|
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.
|
* Returns the publication state of the given chunk.
|
||||||
*
|
*
|
||||||
|
@ -417,7 +417,14 @@ public final class HlsMediaPeriod
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) {
|
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.
|
// HlsSampleStreamWrapper.Callback implementation.
|
||||||
|
@ -30,6 +30,7 @@ import com.google.android.exoplayer2.C;
|
|||||||
import com.google.android.exoplayer2.Format;
|
import com.google.android.exoplayer2.Format;
|
||||||
import com.google.android.exoplayer2.FormatHolder;
|
import com.google.android.exoplayer2.FormatHolder;
|
||||||
import com.google.android.exoplayer2.ParserException;
|
import com.google.android.exoplayer2.ParserException;
|
||||||
|
import com.google.android.exoplayer2.SeekParameters;
|
||||||
import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
|
import com.google.android.exoplayer2.decoder.DecoderInputBuffer;
|
||||||
import com.google.android.exoplayer2.drm.DrmInitData;
|
import com.google.android.exoplayer2.drm.DrmInitData;
|
||||||
import com.google.android.exoplayer2.drm.DrmSession;
|
import com.google.android.exoplayer2.drm.DrmSession;
|
||||||
@ -585,6 +586,31 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
|||||||
&& exclusionDurationMs != C.TIME_UNSET;
|
&& 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.
|
// SampleStream implementation.
|
||||||
|
|
||||||
public boolean isReady(int sampleQueueIndex) {
|
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