diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 07a96bf755..3d9bfed2e4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -43,6 +43,8 @@ delegated in `HlsSampleStreamWrapper` with an incorrect offset causing an `IndexOutOfBoundsException` or an `IllegalArgumentException` ([#1002](https://github.com/androidx/media/issues/1002)). + * Fix bug where non-primary playlists keep reloading for LL-HLS streams + ([#1240](https://github.com/androidx/media/issues/1240)). * DASH Extension: * Smooth Streaming Extension: * RTSP Extension: diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsSampleStreamWrapper.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsSampleStreamWrapper.java index b9961c603c..80c2212cb2 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsSampleStreamWrapper.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsSampleStreamWrapper.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.hls; +import static androidx.media3.exoplayer.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_PRELOAD; import static androidx.media3.exoplayer.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_PUBLISHED; import static androidx.media3.exoplayer.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_REMOVED; import static androidx.media3.exoplayer.trackselection.TrackSelectionUtil.createFallbackOptions; @@ -111,7 +112,11 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * Called to schedule a {@link #continueLoading(LoadingInfo)} call when the playlist referred by - * the given url changes. + * the given url changes, or it requires a refresh to check whether the hinted resource has been + * published or removed. + * + *

Note: This method will be called on a later handler loop than the one on which {@link + * #onPlaylistUpdated()} is invoked. */ void onPlaylistRefreshRequired(Uri playlistUrl); } @@ -543,6 +548,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; int chunkState = chunkSource.getChunkPublicationState(lastMediaChunk); if (chunkState == CHUNK_PUBLICATION_STATE_PUBLISHED) { lastMediaChunk.publish(); + } else if (chunkState == CHUNK_PUBLICATION_STATE_PRELOAD) { + handler.post(() -> callback.onPlaylistRefreshRequired(lastMediaChunk.playlistUrl)); } else if (chunkState == CHUNK_PUBLICATION_STATE_REMOVED && !loadingFinished && loader.isLoading()) { diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java index 1cf9fc71e9..98d7c0c53e 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java @@ -763,13 +763,10 @@ public final class DefaultHlsPlaylistTracker } earliestNextLoadTimeMs = currentTimeMs + Util.usToMs(durationUntilNextLoadUs) - loadEventInfo.loadDurationMs; - // Schedule a load if this is the primary playlist or a playlist of a low-latency stream and - // it doesn't have an end tag. Else the next load will be scheduled when refreshPlaylist is - // called, or when this playlist becomes the primary. - boolean scheduleLoad = - playlistSnapshot.partTargetDurationUs != C.TIME_UNSET - || playlistUrl.equals(primaryMediaPlaylistUrl); - if (scheduleLoad && !playlistSnapshot.hasEndTag) { + // Schedule a load if this is the primary playlist and it doesn't have an end tag. Else the + // next load will be scheduled when refreshPlaylist is called, or when this playlist becomes + // the primary. + if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) { loadPlaylistInternal(getMediaPlaylistUriForReload()); } } diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTrackerTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTrackerTest.java index cc537c9c00..00be7e3c34 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTrackerTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTrackerTest.java @@ -22,6 +22,7 @@ import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DefaultHttpDataSource; import androidx.media3.exoplayer.source.MediaSourceEventListener; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; @@ -31,6 +32,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -365,6 +367,85 @@ public class DefaultHlsPlaylistTrackerTest { assertThat(mediaPlaylists.get(1).trailingParts).hasSize(2); } + @Test + public void start_lowLatencyNotScheduleReloadForNonPrimaryPlaylist() throws Exception { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/multivariant.m3u8", + "/media0/playlist.m3u8", + "/media1/playlist.m3u8", + "/media1/playlist.m3u8", + "/media1/playlist.m3u8?_HLS_msn=14&_HLS_part=0", + }, + getMockResponse(SAMPLE_M3U8_LIVE_MULTIVARIANT), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD_NEXT)); + + DefaultHlsPlaylistTracker defaultHlsPlaylistTracker = + new DefaultHlsPlaylistTracker( + dataType -> new DefaultHttpDataSource.Factory().createDataSource(), + new DefaultLoadErrorHandlingPolicy(), + new DefaultHlsPlaylistParserFactory()); + List mediaPlaylists = new ArrayList<>(); + AtomicInteger playlistCounter = new AtomicInteger(); + AtomicReference primaryPlaylistChangeExceptionRef = new AtomicReference<>(); + defaultHlsPlaylistTracker.addListener( + new HlsPlaylistTracker.PlaylistEventListener() { + @Override + public void onPlaylistChanged() { + // Upon the first call of onPlaylistChanged(), we simulate the situation that the + // primary playlist url changes. + Uri url = defaultHlsPlaylistTracker.getMultivariantPlaylist().mediaPlaylistUrls.get(1); + if (defaultHlsPlaylistTracker.isSnapshotValid(url)) { + return; + } + defaultHlsPlaylistTracker.refreshPlaylist(url); + try { + // Make sure that the playlist for the new url has been refreshed and set it as the + // current primary playlist, before this method returns. + RobolectricUtil.runMainLooperUntil( + () -> + defaultHlsPlaylistTracker.getPlaylistSnapshot(url, /* isForPlayback= */ true) + != null); + } catch (TimeoutException e) { + primaryPlaylistChangeExceptionRef.set(e); + } + } + + @Override + public boolean onPlaylistError( + Uri url, LoadErrorHandlingPolicy.LoadErrorInfo loadErrorInfo, boolean forceRetry) { + return false; + } + }); + + defaultHlsPlaylistTracker.start( + Uri.parse(mockWebServer.url("/multivariant.m3u8").toString()), + new MediaSourceEventListener.EventDispatcher(), + mediaPlaylist -> { + mediaPlaylists.add(mediaPlaylist); + playlistCounter.addAndGet(1); + }); + RobolectricUtil.runMainLooperUntil(() -> playlistCounter.get() >= 2); + defaultHlsPlaylistTracker.stop(); + + assertThat(primaryPlaylistChangeExceptionRef.get()).isNull(); + assertRequestUrlsCalled(httpUrls); + assertThat(mediaPlaylists.get(0).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(0).segments).hasSize(4); + assertThat(mediaPlaylists.get(0).trailingParts).hasSize(1); + assertThat(mediaPlaylists.get(1).mediaSequence).isEqualTo(10); + assertThat(mediaPlaylists.get(1).segments).hasSize(4); + assertThat(mediaPlaylists.get(1).trailingParts).hasSize(2); + } + @Test public void start_httpBadRequest_forcesFullNonBlockingPlaylistRequest() throws IOException, TimeoutException, InterruptedException { diff --git a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant index e595fcaceb..0f8563ce67 100644 --- a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant +++ b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant @@ -3,3 +3,5 @@ #EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="avc1.640028,mp4a.40.2" media0/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="avc1.640028,mp4a.40.2" +media1/playlist.m3u8