From a5ffae17c3a3edc00f7f710044b18d2361479c0b Mon Sep 17 00:00:00 2001 From: tianyifeng Date: Thu, 20 Feb 2025 07:14:24 -0800 Subject: [PATCH] Enable DownloadHelper to create DownloadRequest with byteRange This change only enable the partial support for progressive stream. For now, creating `DownloadRequest` for partial adaptive media will result in an `IllegalStateException`. PiperOrigin-RevId: 729100584 --- .../exoplayer/offline/DownloadHelper.java | 351 ++++++++++++++---- .../exoplayer/offline/DownloadHelperTest.java | 121 +++++- 2 files changed, 406 insertions(+), 66 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadHelper.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadHelper.java index 6da2ab3742..2af0736512 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadHelper.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/offline/DownloadHelper.java @@ -16,13 +16,18 @@ package androidx.media3.exoplayer.offline; import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Util.castNonNull; +import static java.lang.Math.min; +import static java.lang.annotation.ElementType.TYPE_USE; +import static java.lang.annotation.RetentionPolicy.SOURCE; import android.content.Context; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import android.util.SparseIntArray; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -33,11 +38,14 @@ import androidx.media3.common.TrackSelectionOverride; import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; import androidx.media3.common.util.NullableType; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSource; import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.cache.Cache; +import androidx.media3.datasource.cache.CacheDataSource; import androidx.media3.exoplayer.DefaultRendererCapabilitiesList; import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.LoadingInfo; @@ -51,6 +59,7 @@ import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller; +import androidx.media3.exoplayer.source.ProgressiveMediaSource; import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.source.chunk.MediaChunkIterator; @@ -65,7 +74,11 @@ import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.BandwidthMeter; import androidx.media3.exoplayer.upstream.DefaultAllocator; import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.SeekMap; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -118,6 +131,20 @@ public final class DownloadHelper { return DEFAULT_TRACK_SELECTOR_PARAMETERS; } + @Documented + @Retention(SOURCE) + @Target(TYPE_USE) + @IntDef({ + MODE_NOT_PREPARE, + MODE_PREPARE_PROGRESSIVE_SOURCE, + MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS + }) + private @interface Mode {} + + private static final int MODE_NOT_PREPARE = 0; + private static final int MODE_PREPARE_PROGRESSIVE_SOURCE = 1; + private static final int MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS = 2; + /** A callback to be notified when the {@link DownloadHelper} is prepared. */ public interface Callback { @@ -158,6 +185,28 @@ public final class DownloadHelper { /* drmSessionManager= */ null); } + /** + * Creates a {@link DownloadHelper} for the given media item. + * + * @param context The context. + * @param mediaItem A {@link MediaItem}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive + * streams or the {@link SeekMap} for progressive streams. In the latter case, this has to be + * a {@link CacheDataSource.Factory} for the {@link Cache} into which downloads will be + * written. + * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or + * SmoothStreaming media items. + */ + public static DownloadHelper forMediaItem( + Context context, MediaItem mediaItem, DataSource.Factory dataSourceFactory) { + return forMediaItem( + mediaItem, + getDefaultTrackSelectorParameters(context), + /* renderersFactory= */ null, + dataSourceFactory, + /* drmSessionManager= */ null); + } + /** * Creates a {@link DownloadHelper} for the given media item. * @@ -166,9 +215,10 @@ public final class DownloadHelper { * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are * selected. * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive - * streams. This argument is required for adaptive streams and ignored for progressive - * streams. - * @return A {@link DownloadHelper}. + * streams or the {@link SeekMap} for progressive streams. This argument is required for + * adaptive streams or when requesting partial downloads for progressive streams. In the + * latter case, this has to be a {@link CacheDataSource.Factory} for the {@link Cache} into + * which downloads will be written. * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. @@ -195,9 +245,10 @@ public final class DownloadHelper { * @param trackSelectionParameters {@link TrackSelectionParameters} for selecting tracks for * downloading. * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive - * streams. This argument is required for adaptive streams and ignored for progressive - * streams. - * @return A {@link DownloadHelper}. + * streams or the {@link SeekMap} for progressive streams. This argument is required for + * adaptive streams or when requesting partial downloads for progressive streams. In the + * latter case, this has to be a {@link CacheDataSource.Factory} for the {@link Cache} into + * which downloads will be written. * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. @@ -224,11 +275,12 @@ public final class DownloadHelper { * @param trackSelectionParameters {@link TrackSelectionParameters} for selecting tracks for * downloading. * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest for adaptive - * streams. This argument is required for adaptive streams and ignored for progressive - * streams. + * streams or the {@link SeekMap} for progressive streams. This argument is required for + * adaptive streams or when requesting partial downloads for progressive streams. In the + * latter case, this has to be a {@link CacheDataSource.Factory} for the {@link Cache} into + * which downloads will be written. * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which * tracks can be selected. - * @return A {@link DownloadHelper}. * @throws IllegalStateException If the corresponding module is missing for DASH, HLS or * SmoothStreaming media items. * @throws IllegalArgumentException If the {@code dataSourceFactory} is null for adaptive streams. @@ -243,7 +295,7 @@ public final class DownloadHelper { Assertions.checkArgument(isProgressive || dataSourceFactory != null); return new DownloadHelper( mediaItem, - isProgressive + isProgressive && dataSourceFactory == null ? null : createMediaSourceInternal( mediaItem, castNonNull(dataSourceFactory), drmSessionManager), @@ -281,8 +333,11 @@ public final class DownloadHelper { downloadRequest.toMediaItem(), dataSourceFactory, drmSessionManager); } + private static final String TAG = "DownloadHelper"; + private final MediaItem.LocalConfiguration localConfiguration; @Nullable private final MediaSource mediaSource; + private final @Mode int mode; private final DefaultTrackSelector trackSelector; private final RendererCapabilitiesList rendererCapabilities; private final SparseIntArray scratchSet; @@ -290,6 +345,7 @@ public final class DownloadHelper { private final Timeline.Window window; private boolean isPreparedWithMedia; + private boolean areTracksSelected; private @MonotonicNonNull Callback callback; private @MonotonicNonNull MediaPreparer mediaPreparer; private TrackGroupArray @MonotonicNonNull [] trackGroupArrays; @@ -316,6 +372,12 @@ public final class DownloadHelper { RendererCapabilitiesList rendererCapabilities) { this.localConfiguration = checkNotNull(mediaItem.localConfiguration); this.mediaSource = mediaSource; + this.mode = + (mediaSource == null) + ? MODE_NOT_PREPARE + : (mediaSource instanceof ProgressiveMediaSource) + ? MODE_PREPARE_PROGRESSIVE_SOURCE + : MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS; this.trackSelector = new DefaultTrackSelector(trackSelectionParameters, new DownloadTrackSelection.Factory()); this.rendererCapabilities = rendererCapabilities; @@ -334,8 +396,8 @@ public final class DownloadHelper { public void prepare(Callback callback) { Assertions.checkState(this.callback == null); this.callback = callback; - if (mediaSource != null) { - mediaPreparer = new MediaPreparer(mediaSource, /* downloadHelper= */ this); + if (mode != MODE_NOT_PREPARE) { + mediaPreparer = new MediaPreparer(checkNotNull(mediaSource), /* downloadHelper= */ this); } else { callbackHandler.post(() -> callback.onPrepared(this)); } @@ -374,7 +436,7 @@ public final class DownloadHelper { return 0; } assertPreparedWithMedia(); - return trackGroupArrays.length; + return mediaPreparer.mediaPeriods.length; } /** @@ -386,7 +448,7 @@ public final class DownloadHelper { * content. */ public Tracks getTracks(int periodIndex) { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); return TrackSelectionUtil.buildTracks( mappedTrackInfos[periodIndex], immutableTrackSelectionsByPeriodAndRenderer[periodIndex]); } @@ -402,7 +464,7 @@ public final class DownloadHelper { * content. */ public TrackGroupArray getTrackGroups(int periodIndex) { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); return trackGroupArrays[periodIndex]; } @@ -414,7 +476,7 @@ public final class DownloadHelper { * @return The {@link MappedTrackInfo} for the period. */ public MappedTrackInfo getMappedTrackInfo(int periodIndex) { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); return mappedTrackInfos[periodIndex]; } @@ -427,7 +489,7 @@ public final class DownloadHelper { * @return A list of selected {@link ExoTrackSelection track selections}. */ public List getTrackSelections(int periodIndex, int rendererIndex) { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; } @@ -438,7 +500,7 @@ public final class DownloadHelper { * @param periodIndex The period index for which track selections are cleared. */ public void clearTrackSelections(int periodIndex) { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); for (int i = 0; i < rendererCapabilities.size(); i++) { trackSelectionsByPeriodAndRenderer[periodIndex][i].clear(); } @@ -455,7 +517,7 @@ public final class DownloadHelper { public void replaceTrackSelections( int periodIndex, TrackSelectionParameters trackSelectionParameters) { try { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); clearTrackSelections(periodIndex); addTrackSelectionInternal(periodIndex, trackSelectionParameters); } catch (ExoPlaybackException e) { @@ -474,7 +536,7 @@ public final class DownloadHelper { public void addTrackSelection( int periodIndex, TrackSelectionParameters trackSelectionParameters) { try { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); addTrackSelectionInternal(periodIndex, trackSelectionParameters); } catch (ExoPlaybackException e) { throw new IllegalStateException(e); @@ -491,7 +553,7 @@ public final class DownloadHelper { */ public void addAudioLanguagesToSelection(String... languages) { try { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); TrackSelectionParameters.Builder parametersBuilder = DEFAULT_TRACK_SELECTOR_PARAMETERS.buildUpon(); @@ -531,7 +593,7 @@ public final class DownloadHelper { public void addTextLanguagesToSelection( boolean selectUndeterminedTextLanguage, String... languages) { try { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); TrackSelectionParameters.Builder parametersBuilder = DEFAULT_TRACK_SELECTOR_PARAMETERS.buildUpon(); @@ -576,7 +638,7 @@ public final class DownloadHelper { DefaultTrackSelector.Parameters trackSelectorParameters, List overrides) { try { - assertPreparedWithMedia(); + assertPreparedWithNonProgressiveSourceAndTracksSelected(); DefaultTrackSelector.Parameters.Builder builder = trackSelectorParameters.buildUpon(); for (int i = 0; i < mappedTrackInfos[periodIndex].getRendererCount(); i++) { builder.setRendererDisabled(/* rendererIndex= */ i, /* disabled= */ i != rendererIndex); @@ -601,21 +663,72 @@ public final class DownloadHelper { * after preparation completes. The uri of the {@link DownloadRequest} will be used as content id. * * @param data Application provided data to store in {@link DownloadRequest#data}. - * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(@Nullable byte[] data) { return getDownloadRequest(localConfiguration.uri.toString(), data); } + /** + * Builds a {@link DownloadRequest} for downloading the selected tracks and time range. Must not + * be called until preparation completes. + * + *

This method is only supported for progressive streams. + * + * @param data Application provided data to store in {@link DownloadRequest#data}. + * @param startPositionMs The start position (in milliseconds) of the media that download should + * cover from, or {@link C#TIME_UNSET} if the download should cover from the default start + * position. + * @param durationMs The end position (in milliseconds) of the media that download should cover + * to, or {@link C#TIME_UNSET} if the download should cover to the end of the media. If the + * {@code endPositionMs} is larger than the duration of the media, then the download will + * cover to the end of the media. + * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming. + */ + public DownloadRequest getDownloadRequest( + @Nullable byte[] data, long startPositionMs, long durationMs) { + return getDownloadRequest(localConfiguration.uri.toString(), data, startPositionMs, durationMs); + } + /** * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until * after preparation completes. * * @param id The unique content id. * @param data Application provided data to store in {@link DownloadRequest#data}. - * @return The built {@link DownloadRequest}. */ public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { + return getDownloadRequestBuilder(id, data).build(); + } + + /** + * Builds a {@link DownloadRequest} for downloading the selected tracks and time range. Must not + * be called until preparation completes. + * + *

This method is only supported for progressive streams. + * + * @param id The unique content id. + * @param data Application provided data to store in {@link DownloadRequest#data}. + * @param startPositionMs The start position (in milliseconds) of the media that download should + * cover from, or {@link C#TIME_UNSET} if the download should cover from the default start + * position. + * @param durationMs The duration (in milliseconds) of the media that download should cover, or + * {@link C#TIME_UNSET} if the download should cover to the end of the media. If the end + * position resolved from {@code startPositionMs} and {@code durationMs} is beyond the + * duration of the media, then the download will just cover to the end of the media. + * @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming. + */ + public DownloadRequest getDownloadRequest( + String id, @Nullable byte[] data, long startPositionMs, long durationMs) { + checkState( + mode == MODE_PREPARE_PROGRESSIVE_SOURCE, + "Partial download is only supported for progressive streams"); + DownloadRequest.Builder builder = getDownloadRequestBuilder(id, data); + assertPreparedWithProgressiveSource(); + populateDownloadRequestBuilderWithDownloadRange(builder, startPositionMs, durationMs); + return builder.build(); + } + + private DownloadRequest.Builder getDownloadRequestBuilder(String id, @Nullable byte[] data) { DownloadRequest.Builder requestBuilder = new DownloadRequest.Builder(id, localConfiguration.uri) .setMimeType(localConfiguration.mimeType) @@ -625,22 +738,75 @@ public final class DownloadHelper { : null) .setCustomCacheKey(localConfiguration.customCacheKey) .setData(data); - if (mediaSource == null) { - return requestBuilder.build(); - } - assertPreparedWithMedia(); - List streamKeys = new ArrayList<>(); - List allSelections = new ArrayList<>(); - int periodCount = trackSelectionsByPeriodAndRenderer.length; - for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { - allSelections.clear(); - int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length; - for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { - allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]); + if (mode == MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS) { + assertPreparedWithNonProgressiveSourceAndTracksSelected(); + List streamKeys = new ArrayList<>(); + List allSelections = new ArrayList<>(); + int periodCount = trackSelectionsByPeriodAndRenderer.length; + for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { + allSelections.clear(); + int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length; + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]); + } + streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); } - streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); + requestBuilder.setStreamKeys(streamKeys); + } + return requestBuilder; + } + + private void populateDownloadRequestBuilderWithDownloadRange( + DownloadRequest.Builder requestBuilder, long startPositionMs, long durationMs) { + assertPreparedWithProgressiveSource(); + Timeline timeline = mediaPreparer.timeline; + if (mediaPreparer.mediaPeriods.length > 1) { + Log.w(TAG, "Partial download is only supported for single period."); + return; + } + + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + long periodStartPositionUs = + timeline.getPeriodPositionUs( + window, + period, + /* windowIndex= */ 0, + /* windowPositionUs= */ Util.msToUs(startPositionMs)) + .second; + + long periodEndPositionUs = C.TIME_UNSET; + if (durationMs != C.TIME_UNSET) { + periodEndPositionUs = periodStartPositionUs + Util.msToUs(durationMs); + if (period.durationUs != C.TIME_UNSET) { + periodEndPositionUs = min(periodEndPositionUs, period.durationUs - 1); + } + } + + // SeekMap should be available for prepared progressive media. + SeekMap seekMap = mediaPreparer.seekMap; + if (seekMap.isSeekable()) { + long byteRangeStartPositionOffset = + seekMap.getSeekPoints(periodStartPositionUs).first.position; + long byteRangeLength = C.LENGTH_UNSET; + if (periodEndPositionUs != C.TIME_UNSET) { + long byteRangeEndPositionOffset = + seekMap.getSeekPoints(periodEndPositionUs).second.position; + // When the start and end positions are after the last seek point, they will both have only + // that one mapped seek point. Then we should download from that seek point to the end of + // the media, otherwise nothing will be downloaded as the resolved length is 0. + boolean areStartAndEndPositionsAfterTheLastSeekPoint = + periodStartPositionUs != periodEndPositionUs + && byteRangeStartPositionOffset == byteRangeEndPositionOffset; + byteRangeLength = + !areStartAndEndPositionsAfterTheLastSeekPoint + ? byteRangeEndPositionOffset - byteRangeStartPositionOffset + : C.LENGTH_UNSET; + } + requestBuilder.setByteRange(byteRangeStartPositionOffset, byteRangeLength); + } else { + Log.w(TAG, "Cannot set download byte range for progressive stream that is unseekable"); } - return requestBuilder.setStreamKeys(streamKeys).build(); } @RequiresNonNull({ @@ -670,28 +836,34 @@ public final class DownloadHelper { checkNotNull(mediaPreparer); checkNotNull(mediaPreparer.mediaPeriods); checkNotNull(mediaPreparer.timeline); - int periodCount = mediaPreparer.mediaPeriods.length; - int rendererCount = rendererCapabilities.size(); - trackSelectionsByPeriodAndRenderer = - (List[][]) new List[periodCount][rendererCount]; - immutableTrackSelectionsByPeriodAndRenderer = - (List[][]) new List[periodCount][rendererCount]; - for (int i = 0; i < periodCount; i++) { - for (int j = 0; j < rendererCount; j++) { - trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>(); - immutableTrackSelectionsByPeriodAndRenderer[i][j] = - Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]); + if (mode == MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS) { + int periodCount = mediaPreparer.mediaPeriods.length; + int rendererCount = rendererCapabilities.size(); + trackSelectionsByPeriodAndRenderer = + (List[][]) new List[periodCount][rendererCount]; + immutableTrackSelectionsByPeriodAndRenderer = + (List[][]) new List[periodCount][rendererCount]; + for (int i = 0; i < periodCount; i++) { + for (int j = 0; j < rendererCount; j++) { + trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>(); + immutableTrackSelectionsByPeriodAndRenderer[i][j] = + Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]); + } } + trackGroupArrays = new TrackGroupArray[periodCount]; + mappedTrackInfos = new MappedTrackInfo[periodCount]; + for (int i = 0; i < periodCount; i++) { + trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); + TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); + trackSelector.onSelectionActivated(trackSelectorResult.info); + mappedTrackInfos[i] = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + } + setPreparedWithNonProgressiveSourceAndTracksSelected(); + } else { + checkState(mode == MODE_PREPARE_PROGRESSIVE_SOURCE); + checkNotNull(mediaPreparer.seekMap); + setPreparedWithProgressiveSource(); } - trackGroupArrays = new TrackGroupArray[periodCount]; - mappedTrackInfos = new MappedTrackInfo[periodCount]; - for (int i = 0; i < periodCount; i++) { - trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); - TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); - trackSelector.onSelectionActivated(trackSelectorResult.info); - mappedTrackInfos[i] = checkNotNull(trackSelector.getCurrentMappedTrackInfo()); - } - setPreparedWithMedia(); checkNotNull(callbackHandler).post(() -> checkNotNull(callback).onPrepared(this)); } @@ -708,8 +880,26 @@ public final class DownloadHelper { "mediaPreparer.timeline", "mediaPreparer.mediaPeriods" }) - private void setPreparedWithMedia() { + private void setPreparedWithNonProgressiveSourceAndTracksSelected() { isPreparedWithMedia = true; + areTracksSelected = true; + } + + @RequiresNonNull({ + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.seekMap", + "mediaPreparer.mediaPeriods" + }) + private void setPreparedWithProgressiveSource() { + isPreparedWithMedia = true; + } + + @EnsuresNonNull({"mediaPreparer", "mediaPreparer.timeline", "mediaPreparer.mediaPeriods"}) + @SuppressWarnings("nullness:contracts.postcondition") + private void assertPreparedWithMedia() { + Assertions.checkState(mode != MODE_NOT_PREPARE); + Assertions.checkState(isPreparedWithMedia); } @EnsuresNonNull({ @@ -722,7 +912,21 @@ public final class DownloadHelper { "mediaPreparer.mediaPeriods" }) @SuppressWarnings("nullness:contracts.postcondition") - private void assertPreparedWithMedia() { + private void assertPreparedWithNonProgressiveSourceAndTracksSelected() { + Assertions.checkState(mode == MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS); + Assertions.checkState(isPreparedWithMedia); + Assertions.checkState(areTracksSelected); + } + + @EnsuresNonNull({ + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.seekMap", + "mediaPreparer.mediaPeriods" + }) + @SuppressWarnings("nullness:contracts.postcondition") + private void assertPreparedWithProgressiveSource() { + Assertions.checkState(mode == MODE_PREPARE_PROGRESSIVE_SOURCE); Assertions.checkState(isPreparedWithMedia); } @@ -783,8 +987,10 @@ public final class DownloadHelper { MediaItem mediaItem, DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { - DefaultMediaSourceFactory mediaSourceFactory = - new DefaultMediaSourceFactory(dataSourceFactory, ExtractorsFactory.EMPTY); + MediaSource.Factory mediaSourceFactory = + isProgressive(checkNotNull(mediaItem.localConfiguration)) + ? new ProgressiveMediaSource.Factory(dataSourceFactory) + : new DefaultMediaSourceFactory(dataSourceFactory, ExtractorsFactory.EMPTY); if (drmSessionManager != null) { mediaSourceFactory.setDrmSessionManagerProvider(unusedMediaItem -> drmSessionManager); } @@ -798,7 +1004,10 @@ public final class DownloadHelper { } private static final class MediaPreparer - implements MediaSourceCaller, MediaPeriod.Callback, Handler.Callback { + implements MediaSourceCaller, + ProgressiveMediaSource.Listener, + MediaPeriod.Callback, + Handler.Callback { private static final int MESSAGE_PREPARE_SOURCE = 1; private static final int MESSAGE_CHECK_FOR_FAILURE = 2; @@ -817,6 +1026,7 @@ public final class DownloadHelper { private final Handler mediaSourceHandler; public @MonotonicNonNull Timeline timeline; + public @MonotonicNonNull SeekMap seekMap; public MediaPeriod @MonotonicNonNull [] mediaPeriods; private boolean released; @@ -850,6 +1060,9 @@ public final class DownloadHelper { public boolean handleMessage(Message msg) { switch (msg.what) { case MESSAGE_PREPARE_SOURCE: + if (mediaSource instanceof ProgressiveMediaSource) { + ((ProgressiveMediaSource) mediaSource).setListener(this); + } mediaSource.prepareSource( /* caller= */ this, /* mediaTransferListener= */ null, PlayerId.UNSET); mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE); @@ -883,6 +1096,9 @@ public final class DownloadHelper { mediaSource.releasePeriod(period); } } + if (mediaSource instanceof ProgressiveMediaSource) { + ((ProgressiveMediaSource) mediaSource).clearListener(); + } mediaSource.releaseSource(this); mediaSourceHandler.removeCallbacksAndMessages(null); mediaSourceThread.quit(); @@ -924,6 +1140,13 @@ public final class DownloadHelper { } } + // ProgressiveMediaSource.Listener implementation. + + @Override + public void onSeekMap(MediaSource source, SeekMap seekMap) { + this.seekMap = seekMap; + } + // MediaPeriod.Callback implementation. @Override diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/offline/DownloadHelperTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/offline/DownloadHelperTest.java index 287dc01675..375b67cfed 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/offline/DownloadHelperTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/offline/DownloadHelperTest.java @@ -18,8 +18,10 @@ package androidx.media3.exoplayer.offline; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.assertThrows; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; +import android.content.Context; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MediaItem; @@ -447,6 +449,121 @@ public class DownloadHelperTest { new StreamKey(/* periodIndex= */ 0, /* groupIndex= */ 2, /* streamIndex= */ 0)); } + @Test + public void + getDownloadRequest_createsDownloadRequestWithConcreteTimeRange_requestContainsConcreteByteRange() + throws Exception { + Context context = getApplicationContext(); + DownloadHelper downloadHelper = + DownloadHelper.forMediaItem( + context, + MediaItem.fromUri("asset:///media/mp4/long_1080p_lowbitrate.mp4"), + new DefaultDataSource.Factory(context)); + prepareDownloadHelper(downloadHelper); + + DownloadRequest downloadRequest = + downloadHelper.getDownloadRequest( + /* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ 30000); + + assertThat(downloadRequest.byteRange).isNotNull(); + assertThat(downloadRequest.byteRange.offset).isAtLeast(0); + assertThat(downloadRequest.byteRange.length).isGreaterThan(0); + } + + @Test + public void + getDownloadRequest_createsDownloadRequestWithUnsetStartPosition_requestContainsConcreteByteRange() + throws Exception { + Context context = getApplicationContext(); + DownloadHelper downloadHelper = + DownloadHelper.forMediaItem( + context, + MediaItem.fromUri("asset:///media/mp4/long_1080p_lowbitrate.mp4"), + new DefaultDataSource.Factory(context)); + prepareDownloadHelper(downloadHelper); + + DownloadRequest downloadRequest = + downloadHelper.getDownloadRequest( + /* data= */ null, /* startPositionMs= */ C.TIME_UNSET, /* durationMs= */ 30000); + + assertThat(downloadRequest.byteRange).isNotNull(); + assertThat(downloadRequest.byteRange.offset).isAtLeast(0); + assertThat(downloadRequest.byteRange.length).isGreaterThan(0); + } + + @Test + public void getDownloadRequest_createsDownloadRequestWithUnsetLength_requestContainsUnsetLength() + throws Exception { + Context context = getApplicationContext(); + DownloadHelper downloadHelper = + DownloadHelper.forMediaItem( + context, + MediaItem.fromUri("asset:///media/mp4/long_1080p_lowbitrate.mp4"), + new DefaultDataSource.Factory(context)); + prepareDownloadHelper(downloadHelper); + + DownloadRequest downloadRequest = + downloadHelper.getDownloadRequest( + /* data= */ null, /* startPositionMs= */ 30000, /* durationMs= */ C.TIME_UNSET); + + assertThat(downloadRequest.byteRange).isNotNull(); + assertThat(downloadRequest.byteRange.offset).isAtLeast(0); + assertThat(downloadRequest.byteRange.length).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void + getDownloadRequest_createsDownloadRequestForTooShortStreamWithTimeRange_requestContainsUnsetLength() + throws Exception { + Context context = getApplicationContext(); + DownloadHelper downloadHelper = + DownloadHelper.forMediaItem( + context, + MediaItem.fromUri("asset:///media/mp4/sample.mp4"), + new DefaultDataSource.Factory(context)); + prepareDownloadHelper(downloadHelper); + + DownloadRequest downloadRequest = + downloadHelper.getDownloadRequest( + /* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ 30000); + + assertThat(downloadRequest.byteRange).isNotNull(); + assertThat(downloadRequest.byteRange.offset).isAtLeast(0); + assertThat(downloadRequest.byteRange.length).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void + getDownloadRequest_createsDownloadRequestWithoutTimeRange_requestContainsNullByteRange() + throws Exception { + Context context = getApplicationContext(); + DownloadHelper downloadHelper = + DownloadHelper.forMediaItem( + getApplicationContext(), + MediaItem.fromUri("asset:///media/mp4/sample.mp4"), + new DefaultDataSource.Factory(context)); + prepareDownloadHelper(downloadHelper); + + DownloadRequest downloadRequest = downloadHelper.getDownloadRequest(/* data= */ null); + + assertThat(downloadRequest.byteRange).isNull(); + } + + @Test + public void + getDownloadRequest_createDownloadRequestWithTimeRangeForNonProgressiveStream_throwsIllegalStateException() + throws Exception { + // We use this.downloadHelper as it was created with a TestMediaSource, thus the DownloadHelper + // will treat it as non-progressive. + prepareDownloadHelper(downloadHelper); + + assertThrows( + IllegalStateException.class, + () -> + downloadHelper.getDownloadRequest( + /* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ 10000)); + } + // https://github.com/androidx/media/issues/1224 @Test public void prepareThenRelease_renderersReleased() throws Exception { @@ -460,10 +577,10 @@ public class DownloadHelperTest { new Renderer[] {textRenderer, audioRenderer, videoRenderer}; DownloadHelper downloadHelper = DownloadHelper.forMediaItem( - testMediaItem, + MediaItem.fromUri("asset:///media/mp4/sample.mp4"), DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS, renderersFactory, - new FakeDataSource.Factory()); + new DefaultDataSource.Factory(getApplicationContext())); prepareDownloadHelper(downloadHelper); downloadHelper.release();