Skip to content

Commit

Permalink
Merge pull request #9536 from TiVo:p-fix-issue-2882
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 411056555
  • Loading branch information
kim-vde committed Nov 26, 2021
2 parents f138ec9 + 530dd3f commit 4a69e16
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 4 deletions.
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
* Add a method to `AdPlaybackState` to allow resetting an ad group so that
it can be played again
([#9615](https://github.com/google/ExoPlayer/issues/9615)).
* HLS:
* Support key-frame accurate seeking in HLS
([#2882](https://github.com/google/ExoPlayer/issues/2882)).

### 2.16.1 (2021-11-18)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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.analytics.PlayerId;
import com.google.android.exoplayer2.source.BehindLiveWindowException;
import com.google.android.exoplayer2.source.TrackGroup;
Expand Down Expand Up @@ -241,6 +242,53 @@ public void setIsTimestampMaster(boolean isTimestampMaster) {
this.isTimestampMaster = isTimestampMaster;
}

/**
* Adjusts a seek position given the specified {@link 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) {
int selectedIndex = trackSelection.getSelectedIndex();
@Nullable
HlsMediaPlaylist mediaPlaylist =
selectedIndex < playlistUrls.length && selectedIndex != C.INDEX_UNSET
? playlistTracker.getPlaylistSnapshot(
playlistUrls[selectedIndex], /* isForPlayback= */ true)
: null;

if (mediaPlaylist == null
|| mediaPlaylist.segments.isEmpty()
|| !mediaPlaylist.hasIndependentSegments) {
return positionUs;
}

// Segments start with sync samples (i.e., EXT-X-INDEPENDENT-SEGMENTS is set) and the playlist
// is non-empty, so we can use segment start times as sync points. Note that in the rare case
// that (a) an adaptive quality switch occurs between the adjustment and the seek being
// performed, and (b) segment start times are not aligned across variants, it's possible that
// the adjusted position may not be at a sync point when it was intended to be. However, this is
// very much an edge case, and getting it wrong is worth it for getting the vast majority of
// cases right whilst keeping the implementation relatively simple.
long startOfPlaylistInPeriodUs =
mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs();
long relativePositionUs = positionUs - startOfPlaylistInPeriodUs;
int segmentIndex =
Util.binarySearchFloor(
mediaPlaylist.segments,
relativePositionUs,
/* inclusive= */ true,
/* stayInBounds= */ true);
long firstSyncUs = mediaPlaylist.segments.get(segmentIndex).relativeStartTimeUs;
long secondSyncUs = firstSyncUs;
if (segmentIndex != mediaPlaylist.segments.size() - 1) {
secondSyncUs = mediaPlaylist.segments.get(segmentIndex + 1).relativeStartTimeUs;
}
return seekParameters.resolveSeekPositionUs(relativePositionUs, firstSyncUs, secondSyncUs)
+ startOfPlaylistInPeriodUs;
}

/**
* Returns the publication state of the given chunk.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,14 @@ public long seekToUs(long positionUs) {

@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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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;
Expand Down Expand Up @@ -584,6 +585,22 @@ public boolean onPlaylistError(Uri playlistUrl, LoadErrorInfo loadErrorInfo, boo
&& exclusionDurationMs != C.TIME_UNSET;
}

/** Returns whether the primary sample stream is {@link C#TRACK_TYPE_VIDEO}. */
public boolean isVideoSampleStream() {
return primarySampleQueueType == C.TRACK_TYPE_VIDEO;
}

/**
* Adjusts a seek position given the specified {@link 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright 2021 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 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.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.analytics.PlayerId;
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.testutil.FakeDataSource;
import com.google.android.exoplayer2.testutil.TestUtil;
import java.io.IOException;
import java.io.InputStream;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;

/** Unit tests for {@link HlsChunkSource}. */
@RunWith(AndroidJUnit4.class)
public class HlsChunkSourceTest {

private static final String PLAYLIST = "media/m3u8/media_playlist";
private static final String PLAYLIST_INDEPENDENT_SEGMENTS =
"media/m3u8/media_playlist_independent_segments";
private static final String PLAYLIST_EMPTY = "media/m3u8/media_playlist_empty";
private static final Uri PLAYLIST_URI = Uri.parse("http://example.com/");
private static final long PLAYLIST_START_PERIOD_OFFSET_US = 8_000_000L;

private final HlsExtractorFactory mockExtractorFactory = HlsExtractorFactory.DEFAULT;

@Mock private HlsPlaylistTracker mockPlaylistTracker;
private HlsChunkSource testChunkSource;

@Before
public void setup() throws IOException {
mockPlaylistTracker = Mockito.mock(HlsPlaylistTracker.class);

InputStream inputStream =
TestUtil.getInputStream(
ApplicationProvider.getApplicationContext(), PLAYLIST_INDEPENDENT_SEGMENTS);
HlsMediaPlaylist playlist =
(HlsMediaPlaylist) new HlsPlaylistParser().parse(PLAYLIST_URI, inputStream);
when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean()))
.thenReturn(playlist);

testChunkSource =
new HlsChunkSource(
mockExtractorFactory,
mockPlaylistTracker,
new Uri[] {PLAYLIST_URI},
new Format[] {ExoPlayerTestRunner.VIDEO_FORMAT},
new DefaultHlsDataSourceFactory(new FakeDataSource.Factory()),
/* mediaTransferListener= */ null,
new TimestampAdjusterProvider(),
/* muxedCaptionFormats= */ null,
PlayerId.UNSET);

when(mockPlaylistTracker.isSnapshotValid(eq(PLAYLIST_URI))).thenReturn(true);
// Mock that segments totalling PLAYLIST_START_PERIOD_OFFSET_US in duration have been removed
// from the start of the playlist.
when(mockPlaylistTracker.getInitialStartTimeUs())
.thenReturn(playlist.startTimeUs - PLAYLIST_START_PERIOD_OFFSET_US);
}

@Test
public void getAdjustedSeekPositionUs_previousSync() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.PREVIOUS_SYNC);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(16_000_000);
}

@Test
public void getAdjustedSeekPositionUs_nextSync() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.NEXT_SYNC);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(20_000_000);
}

@Test
public void getAdjustedSeekPositionUs_nextSyncAtEnd() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(24_000_000), SeekParameters.NEXT_SYNC);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(24_000_000);
}

@Test
public void getAdjustedSeekPositionUs_closestSyncBefore() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.CLOSEST_SYNC);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(16_000_000);
}

@Test
public void getAdjustedSeekPositionUs_closestSyncAfter() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(19_000_000), SeekParameters.CLOSEST_SYNC);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(20_000_000);
}

@Test
public void getAdjustedSeekPositionUs_exact() {
long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.EXACT);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(17_000_000);
}

@Test
public void getAdjustedSeekPositionUs_noIndependentSegments() throws IOException {
InputStream inputStream =
TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), PLAYLIST);
HlsMediaPlaylist playlist =
(HlsMediaPlaylist) new HlsPlaylistParser().parse(PLAYLIST_URI, inputStream);
when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean()))
.thenReturn(playlist);

long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(100_000_000), SeekParameters.EXACT);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(100_000_000);
}

@Test
public void getAdjustedSeekPositionUs_emptyPlaylist() throws IOException {
InputStream inputStream =
TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), PLAYLIST_EMPTY);
HlsMediaPlaylist playlist =
(HlsMediaPlaylist) new HlsPlaylistParser().parse(PLAYLIST_URI, inputStream);
when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean()))
.thenReturn(playlist);

long adjustedPositionUs =
testChunkSource.getAdjustedSeekPositionUs(
playlistTimeToPeriodTimeUs(100_000_000), SeekParameters.EXACT);

assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(100_000_000);
}

private static long playlistTimeToPeriodTimeUs(long playlistTimeUs) {
return playlistTimeUs + PLAYLIST_START_PERIOD_OFFSET_US;
}

private static long periodTimeToPlaylistTimeUs(long periodTimeUs) {
return periodTimeUs - PLAYLIST_START_PERIOD_OFFSET_US;
}
}
16 changes: 16 additions & 0 deletions testdata/src/test/assets/media/m3u8/media_playlist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:2
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4,
2.mp4
#EXTINF:4,
3.mp4
#EXTINF:4,
4.mp4
#EXTINF:4,
5.mp4
#EXTINF:4,
6.mp4
#EXTINF:4,
7.mp4
#EXT-X-ENDLIST
3 changes: 3 additions & 0 deletions testdata/src/test/assets/media/m3u8/media_playlist_empty
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:2
#EXT-X-MAP:URI="init.mp4"
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#EXTM3U
#EXT-X-MEDIA-SEQUENCE:2
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4,
2.mp4
#EXTINF:4,
3.mp4
#EXTINF:4,
4.mp4
#EXTINF:4,
5.mp4
#EXTINF:4,
6.mp4
#EXTINF:4,
7.mp4
#EXT-X-ENDLIST
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.util.ArrayList;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;

/**
* A fake {@link DataSource} capable of simulating various scenarios. It uses a {@link FakeDataSet}
Expand All @@ -43,9 +42,13 @@ public class FakeDataSource extends BaseDataSource {
/** Factory to create a {@link FakeDataSource}. */
public static class Factory implements DataSource.Factory {

protected @MonotonicNonNull FakeDataSet fakeDataSet;
protected FakeDataSet fakeDataSet;
protected boolean isNetwork;

public Factory() {
fakeDataSet = new FakeDataSet();
}

public final Factory setFakeDataSet(FakeDataSet fakeDataSet) {
this.fakeDataSet = fakeDataSet;
return this;
Expand All @@ -58,7 +61,7 @@ public final Factory setIsNetwork(boolean isNetwork) {

@Override
public FakeDataSource createDataSource() {
return new FakeDataSource(Assertions.checkStateNotNull(fakeDataSet), isNetwork);
return new FakeDataSource(fakeDataSet, isNetwork);
}
}

Expand Down

0 comments on commit 4a69e16

Please sign in to comment.