diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bfbbe540f6..772d311576 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -55,6 +55,8 @@ * Cronet Extension: * RTMP Extension: * HLS Extension: + * Fix a bug where non-primary playing playlists are not refreshed during + live playback ([#1240](https://github.com/androidx/media/issues/1240)). * Smooth Streaming Extension: * RTSP Extension: * Decoder Extensions (FFmpeg, VP9, AV1, etc.): diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java index 4e19ae8eba..8722f3ffd0 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java @@ -253,6 +253,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * @param trackSelection The {@link ExoTrackSelection}. */ public void setTrackSelection(ExoTrackSelection trackSelection) { + // Deactivate the selected playlist from the old track selection for playback. + deactivatePlaylistForSelectedTrack(); this.trackSelection = trackSelection; } @@ -263,6 +265,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** Resets the source. */ public void reset() { + deactivatePlaylistForSelectedTrack(); fatalError = null; } @@ -463,6 +466,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; partIndex = nextMediaSequenceAndPartIndexWithoutAdapting.second; } + // If the selected track index changes from another one, we should deactivate the old playlist + // for playback. + if (selectedTrackIndex != oldTrackIndex && oldTrackIndex != C.INDEX_UNSET) { + Uri oldPlaylistUrl = playlistUrls[oldTrackIndex]; + playlistTracker.deactivatePlaylistForPlayback(oldPlaylistUrl); + } + if (chunkMediaSequence < playlist.mediaSequence) { fatalError = new BehindLiveWindowException(); return; @@ -944,6 +954,11 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return UriUtil.resolveToUri(playlist.baseUri, segmentBase.fullSegmentEncryptionKeyUri); } + private void deactivatePlaylistForSelectedTrack() { + int selectedTrackIndex = this.trackSelection.getSelectedIndexInTrackGroup(); + playlistTracker.deactivatePlaylistForPlayback(playlistUrls[selectedTrackIndex]); + } + // Package classes. /* package */ static final class SegmentBaseHolder { 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 80c2212cb2..8c6bcd09e2 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 @@ -565,6 +565,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; sampleQueue.preRelease(); } } + chunkSource.reset(); loader.release(this); handler.removeCallbacksAndMessages(null); released = true; 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 32ec51e4be..9e2d786d18 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 @@ -191,9 +191,11 @@ public final class DefaultHlsPlaylistTracker @Override @Nullable public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) { - @Nullable HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot(); + MediaPlaylistBundle bundle = playlistBundles.get(url); + @Nullable HlsMediaPlaylist snapshot = bundle.getPlaylistSnapshot(); if (snapshot != null && isForPlayback) { maybeSetPrimaryUrl(url); + maybeActivateForPlayback(url); } return snapshot; } @@ -242,6 +244,14 @@ public final class DefaultHlsPlaylistTracker return false; } + @Override + public void deactivatePlaylistForPlayback(Uri url) { + @Nullable MediaPlaylistBundle bundle = playlistBundles.get(url); + if (bundle != null) { + bundle.setActiveForPlayback(false); + } + } + // Loader.Callback implementation. @Override @@ -352,7 +362,7 @@ public final class DefaultHlsPlaylistTracker || !isVariantUrl(url) || (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.hasEndTag)) { // Ignore if the primary media playlist URL is unchanged, if the media playlist is not - // referenced directly by a variant, or it the last primary snapshot contains an end tag. + // referenced directly by a variant, or if the last primary snapshot contains an end tag. return; } primaryMediaPlaylistUrl = url; @@ -368,6 +378,20 @@ public final class DefaultHlsPlaylistTracker } } + private void maybeActivateForPlayback(Uri url) { + MediaPlaylistBundle playlistBundle = playlistBundles.get(url); + @Nullable HlsMediaPlaylist playlistSnapshot = playlistBundle.getPlaylistSnapshot(); + if (playlistBundle.isActiveForPlayback()) { + return; + } + playlistBundle.setActiveForPlayback(true); + if (playlistSnapshot != null && !playlistSnapshot.hasEndTag) { + // For playlist that doesn't contain an end tag, we should trigger another load for it, as + // the snapshot for it may be stale and it can keep refreshing as an active playlist. + playlistBundle.loadPlaylist(true); + } + } + private Uri getRequestUriForPrimaryChange(Uri newPrimaryPlaylistUri) { if (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.serverControl.canBlockReload) { @@ -528,6 +552,7 @@ public final class DefaultHlsPlaylistTracker private long excludeUntilMs; private boolean loadPending; @Nullable private IOException playlistError; + private boolean activeForPlayback; public MediaPlaylistBundle(Uri playlistUrl) { this.playlistUrl = playlistUrl; @@ -563,6 +588,14 @@ public final class DefaultHlsPlaylistTracker } } + public boolean isActiveForPlayback() { + return activeForPlayback; + } + + public void setActiveForPlayback(boolean activeForPlayback) { + this.activeForPlayback = activeForPlayback; + } + public void release() { mediaPlaylistLoader.release(); } @@ -763,10 +796,11 @@ public final class DefaultHlsPlaylistTracker } earliestNextLoadTimeMs = currentTimeMs + Util.usToMs(durationUntilNextLoadUs) - loadEventInfo.loadDurationMs; - // 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) { + // Schedule a load if this is the primary or playback 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 or active for playback. + if (!playlistSnapshot.hasEndTag + && (playlistUrl.equals(primaryMediaPlaylistUrl) || activeForPlayback)) { loadPlaylistInternal(getMediaPlaylistUriForReload()); } } diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistTracker.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistTracker.java index d7eff3ca05..d08874232a 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistTracker.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/HlsPlaylistTracker.java @@ -235,4 +235,13 @@ public interface HlsPlaylistTracker { * @return True if the content is live. False otherwise. */ boolean isLive(); + + /** + * Deactivate the playlist for playback. + * + *

The default implementation is a no-op. + * + * @param url The {@link Uri} of the playlist to deactivate for playback. + */ + default void deactivatePlaylistForPlayback(Uri url) {} } 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 1a23152d88..23000b89c7 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 @@ -50,6 +50,8 @@ public class DefaultHlsPlaylistTrackerTest { "media/m3u8/live_low_latency_multivariant"; private static final String SAMPLE_M3U8_LIVE_MULTIVARIANT_MEDIA_URI_WITH_PARAM = "media/m3u8/live_low_latency_multivariant_media_uri_with_param"; + private static final String SAMPLE_M3U8_LIVE_MULTIVARIANT_WITH_AUDIO_RENDITIONS = + "media/m3u8/live_low_latency_multivariant_with_audio_renditions"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL = "media/m3u8/live_low_latency_media_can_skip_until"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_SKIP_UNTIL_FULL_RELOAD_AFTER_ERROR = @@ -75,12 +77,21 @@ public class DefaultHlsPlaylistTrackerTest { "media/m3u8/live_low_latency_media_can_block_reload_low_latency_next"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT = "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_NEXT = "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_NEXT2 = "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_next2"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO_NEXT = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next"; + private static final String + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO_NEXT2 = + "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next2"; private static final String SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_PRELOAD = "media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_preload"; @@ -371,7 +382,87 @@ public class DefaultHlsPlaylistTrackerTest { } @Test - public void start_lowLatencyNotScheduleReloadForNonPrimaryPlaylist() throws Exception { + public void start_lowLatencyScheduleReloadForPlayingButNonPrimaryPlaylist() throws Exception { + List httpUrls = + enqueueWebServerResponses( + new String[] { + "/multivariant.m3u8", + "/media0/playlist.m3u8", + "/english/audio-playlist.m3u8", + "/english/audio-playlist.m3u8?_HLS_msn=14&_HLS_part=0", + "/english/audio-playlist.m3u8?_HLS_msn=14&_HLS_part=1", + }, + getMockResponse(SAMPLE_M3U8_LIVE_MULTIVARIANT_WITH_AUDIO_RENDITIONS), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT), + getMockResponse(SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO_NEXT), + getMockResponse( + SAMPLE_M3U8_LIVE_MEDIA_CAN_BLOCK_RELOAD_LOW_LATENCY_FULL_SEGMENT_AUDIO_NEXT2)); + + DefaultHlsPlaylistTracker defaultHlsPlaylistTracker = + new DefaultHlsPlaylistTracker( + dataType -> new DefaultHttpDataSource.Factory().createDataSource(), + new DefaultLoadErrorHandlingPolicy(), + new DefaultHlsPlaylistParserFactory()); + AtomicInteger playlistChangedCounter = new AtomicInteger(); + AtomicReference audioPlaylistRefreshExceptionRef = new AtomicReference<>(); + defaultHlsPlaylistTracker.addListener( + new HlsPlaylistTracker.PlaylistEventListener() { + @Override + public void onPlaylistChanged() { + playlistChangedCounter.addAndGet(1); + // Upon the first call of onPlaylistChanged(), we simulate the situation that the first + // audio rendition is chosen for playback. + Uri url = defaultHlsPlaylistTracker.getMultivariantPlaylist().audios.get(0).url; + if (!defaultHlsPlaylistTracker.isSnapshotValid(url)) { + defaultHlsPlaylistTracker.refreshPlaylist(url); + try { + // Make sure that the audio playlist has been refreshed and we've got a playlist + // snapshot of it. + RobolectricUtil.runMainLooperUntil( + () -> defaultHlsPlaylistTracker.isSnapshotValid(url)); + } catch (TimeoutException e) { + audioPlaylistRefreshExceptionRef.set(e); + } + // Simulate the operations in HlsChunkSource where we keep loading and get a playlist + // snapshot of the given url when there is a valid snapshot available. + defaultHlsPlaylistTracker.getPlaylistSnapshot(url, /* isForPlayback= */ true); + // We have to force the expected audio playlists to load in the first call of + // onPlaylistChanged(), as once this method returned for "/media0/playlist.m3u8", the + // DefaultHlsPlaylistTracker will continue reloading the primary playlist, and it's + // hard to make the order of loading primary and audio playlists deterministic. Thus, + // we will verify if audio playlists are reloaded as expected first, and ignore the + // reloading of primary playlists, whose behaviour was already verified in the other + // tests. + try { + RobolectricUtil.runMainLooperUntil(() -> playlistChangedCounter.get() >= 4); + } catch (TimeoutException e) { + audioPlaylistRefreshExceptionRef.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 -> {}); + RobolectricUtil.runMainLooperUntil(() -> playlistChangedCounter.get() >= 4); + defaultHlsPlaylistTracker.stop(); + + assertThat(audioPlaylistRefreshExceptionRef.get()).isNull(); + assertRequestUrlsCalled(httpUrls); + } + + @Test + public void start_lowLatencyNotScheduleReloadForNonPlayingPlaylist() throws Exception { List httpUrls = enqueueWebServerResponses( new String[] { diff --git a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio new file mode 100644 index 0000000000..8a55957b24 --- /dev/null +++ b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio @@ -0,0 +1,18 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.aac +#EXTINF:4.00000, +fileSequence11.aac +#EXTINF:4.00000, +fileSequence12.aac +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.aac" +#EXTINF:4.00000, +fileSequence13.aac diff --git a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next new file mode 100644 index 0000000000..ce86badce6 --- /dev/null +++ b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next @@ -0,0 +1,19 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.aac +#EXTINF:4.00000, +fileSequence11.aac +#EXTINF:4.00000, +fileSequence12.aac +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.aac" +#EXTINF:4.00000, +fileSequence13.aac +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.aac" diff --git a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next2 b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next2 new file mode 100644 index 0000000000..f1264237f7 --- /dev/null +++ b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_media_can_block_reload_low_latency_full_segment_audio_next2 @@ -0,0 +1,20 @@ +#EXTM3U +#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES +#EXT-X-TARGETDURATION:4 +#EXT-X-PART-INF:PART-TARGET=1.000000 +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:4.00000, +fileSequence10.aac +#EXTINF:4.00000, +fileSequence11.aac +#EXTINF:4.00000, +fileSequence12.aac +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.0.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.1.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.2.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence13.3.aac" +#EXTINF:4.00000, +fileSequence13.aac +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.0.aac" +#EXT-X-PART:DURATION=1.00000,URI="fileSequence14.1.aac" diff --git a/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant_with_audio_renditions b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant_with_audio_renditions new file mode 100644 index 0000000000..cabee1fa54 --- /dev/null +++ b/libraries/test_data/src/test/assets/media/m3u8/live_low_latency_multivariant_with_audio_renditions @@ -0,0 +1,10 @@ +#EXTM3U +#EXT-X-INDEPENDENT-SEGMENTS + +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="English", DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en", URI="english/audio-playlist.m3u8" +#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",NAME="Deutsch", DEFAULT=NO,AUTOSELECT=YES,LANGUAGE="de", URI="german/audio-playlist.m3u8" + +#EXT-X-STREAM-INF:BANDWIDTH=2000000,CODECS="avc1.640028,mp4a.40.2",AUDIO="aac" +media0/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS="avc1.640028,mp4a.40.2",AUDIO="aac" +media1/playlist.m3u8