diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index b29b2e92b0e..f970c1ad124 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -17,6 +17,7 @@ body: label: Media3 Version description: What version of Media3 are you using? options: + - 1.0.0-beta02 - 1.0.0-beta01 - 1.0.0-alpha03 - 1.0.0-alpha02 diff --git a/RELEASENOTES.md b/RELEASENOTES.md index febe027e2e9..70e9bcdf25e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,13 +1,47 @@ Release notes -### Unreleased changes +### 1.0.0-beta02 (2022-07-21) +This release corresponds to the +[ExoPlayer 2.18.1 release](https://github.com/google/ExoPlayer/releases/tag/r2.18.1). + +* Core library: + * Ensure that changing the `ShuffleOrder` with `ExoPlayer.setShuffleOrder` + results in a call to `Player.Listener#onTimelineChanged` with + `reason=Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED` + ([#9889](https://github.com/google/ExoPlayer/issues/9889)). + * For progressive media, only include selected tracks in buffered position + ([#10361](https://github.com/google/ExoPlayer/issues/10361)). + * Allow custom logger for all ExoPlayer log output + ([#9752](https://github.com/google/ExoPlayer/issues/9752)). + * Fix implementation of `setDataSourceFactory` in + `DefaultMediaSourceFactory`, which was non-functional in some cases + ([#116](https://github.com/androidx/media/issues/116)). * Extractors: - * Add support for AVI - ([#2092](https://github.com/google/ExoPlayer/issues/2092)). + * Fix parsing of H265 short term reference picture sets + ([#10316](https://github.com/google/ExoPlayer/issues/10316)). + * Fix parsing of bitrates from `esds` boxes + ([#10381](https://github.com/google/ExoPlayer/issues/10381)). +* DASH: + * Parse ClearKey license URL from manifests + ([#10246](https://github.com/google/ExoPlayer/issues/10246)). +* UI: + * Ensure TalkBack announces the currently active speed option in the + playback controls menu + ([#10298](https://github.com/google/ExoPlayer/issues/10298)). * RTSP: - * Add RTP reader for H263 - ([#63](https://github.com/androidx/media/pull/63)). + * Add VP8 fragmented packet handling + ([#110](https://github.com/androidx/media/pull/110)). +* Leanback extension: + * Listen to `playWhenReady` changes in `LeanbackAdapter` + ([10420](https://github.com/google/ExoPlayer/issues/10420)). +* Cast: + * Use the `MediaItem` that has been passed to the playlist methods as + `Window.mediaItem` in `CastTimeline` + ([#25](https://github.com/androidx/media/issues/25), + [#8212](https://github.com/google/ExoPlayer/issues/8212)). + * Support `Player.getMetadata()` and `Listener.onMediaMetadataChanged()` + with `CastPlayer` ([#25](https://github.com/androidx/media/issues/25)). ### 1.0.0-beta01 (2022-06-16) @@ -46,7 +80,9 @@ This release corresponds to the * Rename `TracksInfo` to `Tracks` and `TracksInfo.TrackGroupInfo` to `Tracks.Group`. `Player.getCurrentTracksInfo` and `Player.Listener.onTracksInfoChanged` have also been renamed to - `Player.getCurrentTracks` and `Player.Listener.onTracksChanged`. + `Player.getCurrentTracks` and `Player.Listener.onTracksChanged`. This + includes 'un-deprecating' the `Player.Listener.onTracksChanged` method + name, but with different parameter types. * Change `DefaultTrackSelector.buildUponParameters` and `DefaultTrackSelector.Parameters.buildUpon` to return `DefaultTrackSelector.Parameters.Builder` instead of the deprecated @@ -100,6 +136,8 @@ This release corresponds to the * Remove `RawCcExtractor`, which was only used to handle a Google-internal subtitle format. * Extractors: + * Add support for AVI + ([#2092](https://github.com/google/ExoPlayer/issues/2092)). * Matroska: Parse `DiscardPadding` for Opus tracks. * MP4: Parse bitrates from `esds` boxes. * Ogg: Allow duplicate Opus ID and comment headers @@ -149,6 +187,8 @@ This release corresponds to the of `DefaultCompositeSequenceableLoaderFactory` can be passed explicitly if required. * RTSP: + * Add RTP reader for H263 + ([#63](https://github.com/androidx/media/pull/63)). * Add RTP reader for MPEG4 ([#35](https://github.com/androidx/media/pull/35)). * Add RTP reader for HEVC @@ -211,10 +251,11 @@ This release corresponds to the AndroidStudio's gradle sync to fail ([#9933](https://github.com/google/ExoPlayer/issues/9933)). * Remove deprecated symbols: - * Remove `Player.Listener.onTracksChanged`. Use - `Player.Listener.onTracksInfoChanged` instead. + * Remove `Player.Listener.onTracksChanged(TrackGroupArray, + TrackSelectionArray)`. Use `Player.Listener.onTracksChanged(Tracks)` + instead. * Remove `Player.getCurrentTrackGroups` and - `Player.getCurrentTrackSelections`. Use `Player.getCurrentTracksInfo` + `Player.getCurrentTrackSelections`. Use `Player.getCurrentTracks` instead. You can also continue to use `ExoPlayer.getCurrentTrackGroups` and `ExoPlayer.getCurrentTrackSelections`, although these methods remain deprecated. @@ -424,7 +465,7 @@ This release corresponds to the when creating `PendingIntent`s ([#9528](https://github.com/google/ExoPlayer/issues/9528)). * Remove deprecated symbols: - * Remove `Player.EventLister`. Use `Player.Listener` instead. + * Remove `Player.EventListener`. Use `Player.Listener` instead. * Remove `MediaSourceFactory.setDrmSessionManager`, `MediaSourceFactory.setDrmHttpDataSourceFactory`, and `MediaSourceFactory.setDrmUserAgent`. Use diff --git a/api.txt b/api.txt index c4e42f5772a..c2f1a94d1a3 100644 --- a/api.txt +++ b/api.txt @@ -807,7 +807,7 @@ package androidx.media3.common { field public static final int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; // 0x1 } - @IntDef({androidx.media3.common.Player.COMMAND_INVALID, androidx.media3.common.Player.COMMAND_PLAY_PAUSE, androidx.media3.common.Player.COMMAND_PREPARE, androidx.media3.common.Player.COMMAND_STOP, androidx.media3.common.Player.COMMAND_SEEK_TO_DEFAULT_POSITION, androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS, androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT, androidx.media3.common.Player.COMMAND_SEEK_TO_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_BACK, androidx.media3.common.Player.COMMAND_SEEK_FORWARD, androidx.media3.common.Player.COMMAND_SET_SPEED_AND_PITCH, androidx.media3.common.Player.COMMAND_SET_SHUFFLE_MODE, androidx.media3.common.Player.COMMAND_SET_REPEAT_MODE, androidx.media3.common.Player.COMMAND_GET_CURRENT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_GET_TIMELINE, androidx.media3.common.Player.COMMAND_GET_MEDIA_ITEMS_METADATA, androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEMS_METADATA, androidx.media3.common.Player.COMMAND_CHANGE_MEDIA_ITEMS, androidx.media3.common.Player.COMMAND_GET_AUDIO_ATTRIBUTES, androidx.media3.common.Player.COMMAND_GET_VOLUME, androidx.media3.common.Player.COMMAND_GET_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_SET_VOLUME, androidx.media3.common.Player.COMMAND_SET_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_ADJUST_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_SET_VIDEO_SURFACE, androidx.media3.common.Player.COMMAND_GET_TEXT, androidx.media3.common.Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS, androidx.media3.common.Player.COMMAND_GET_TRACKS, androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.Command { + @IntDef({androidx.media3.common.Player.COMMAND_INVALID, androidx.media3.common.Player.COMMAND_PLAY_PAUSE, androidx.media3.common.Player.COMMAND_PREPARE, androidx.media3.common.Player.COMMAND_STOP, androidx.media3.common.Player.COMMAND_SEEK_TO_DEFAULT_POSITION, androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS, androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT, androidx.media3.common.Player.COMMAND_SEEK_TO_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_SEEK_BACK, androidx.media3.common.Player.COMMAND_SEEK_FORWARD, androidx.media3.common.Player.COMMAND_SET_SPEED_AND_PITCH, androidx.media3.common.Player.COMMAND_SET_SHUFFLE_MODE, androidx.media3.common.Player.COMMAND_SET_REPEAT_MODE, androidx.media3.common.Player.COMMAND_GET_CURRENT_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_GET_TIMELINE, androidx.media3.common.Player.COMMAND_GET_MEDIA_ITEMS_METADATA, androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEMS_METADATA, androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM, androidx.media3.common.Player.COMMAND_CHANGE_MEDIA_ITEMS, androidx.media3.common.Player.COMMAND_GET_AUDIO_ATTRIBUTES, androidx.media3.common.Player.COMMAND_GET_VOLUME, androidx.media3.common.Player.COMMAND_GET_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_SET_VOLUME, androidx.media3.common.Player.COMMAND_SET_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_ADJUST_DEVICE_VOLUME, androidx.media3.common.Player.COMMAND_SET_VIDEO_SURFACE, androidx.media3.common.Player.COMMAND_GET_TEXT, androidx.media3.common.Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS, androidx.media3.common.Player.COMMAND_GET_TRACKS}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.Command { } public static final class Player.Commands { diff --git a/constants.gradle b/constants.gradle index 86c624e778a..b72c48b65c3 100644 --- a/constants.gradle +++ b/constants.gradle @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. project.ext { - releaseVersion = '1.0.0-beta01' - releaseVersionCode = 1_000_000_1_01 + releaseVersion = '1.0.0-beta02' + releaseVersionCode = 1_000_000_1_02 minSdkVersion = 16 appTargetSdkVersion = 29 // Upgrading this requires [Internal ref: b/193254928] to be fixed, or some diff --git a/demos/main/src/main/AndroidManifest.xml b/demos/main/src/main/AndroidManifest.xml index 3cffe0fa543..76fc35d287f 100644 --- a/demos/main/src/main/AndroidManifest.xml +++ b/demos/main/src/main/AndroidManifest.xml @@ -78,6 +78,12 @@ + + + + + + diff --git a/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt b/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt index be7c324e36d..9abfd650563 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/MainActivity.kt @@ -27,6 +27,7 @@ import android.widget.ArrayAdapter import android.widget.ListView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.session.LibraryResult import androidx.media3.session.MediaBrowser @@ -164,7 +165,7 @@ class MainActivity : AppCompatActivity() { val root: MediaItem = result.value!! pushPathStack(root) }, - MoreExecutors.directExecutor() + ContextCompat.getMainExecutor(this) ) } diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlayableFolderActivity.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlayableFolderActivity.kt index f1c1631d451..125b0e0b8a1 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlayableFolderActivity.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlayableFolderActivity.kt @@ -29,6 +29,7 @@ import android.widget.LinearLayout import android.widget.ListView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaBrowser @@ -150,7 +151,7 @@ class PlayableFolderActivity : AppCompatActivity() { val result = mediaItemFuture.get()!! title.text = result.value!!.mediaMetadata.title }, - MoreExecutors.directExecutor() + ContextCompat.getMainExecutor(this) ) childrenFuture.addListener( { @@ -161,7 +162,7 @@ class PlayableFolderActivity : AppCompatActivity() { subItemMediaList.addAll(children) mediaListAdapter.notifyDataSetChanged() }, - MoreExecutors.directExecutor() + ContextCompat.getMainExecutor(this) ) } diff --git a/demos/surface/src/main/AndroidManifest.xml b/demos/surface/src/main/AndroidManifest.xml index 2c009ed2ccf..e33c9e7242c 100644 --- a/demos/surface/src/main/AndroidManifest.xml +++ b/demos/surface/src/main/AndroidManifest.xml @@ -43,6 +43,12 @@ + + + + + + diff --git a/demos/transformer/src/main/AndroidManifest.xml b/demos/transformer/src/main/AndroidManifest.xml index 5006e431c17..ff7e08db743 100644 --- a/demos/transformer/src/main/AndroidManifest.xml +++ b/demos/transformer/src/main/AndroidManifest.xml @@ -49,6 +49,12 @@ + + + + + + ] [-f] PROJECT_ROOT]" + echo " PROJECT_ROOT: path to your project root (location of 'gradlew')" + echo " -p: list package mappings and then exit" + echo " -c: list class mappings (precedence over package mappings) and then exit" + echo " -d: list dependency mappings and then exit" + echo " -m: migrate packages, classes and dependencies to AndroidX Media3" + echo " -l: list files that will be considered for rewrite and then exit" + echo " -x: exclude the path from the list of file to be changed: 'app/src/test'" + echo " -f: force the action even when validation fails" + echo " -v: print the exoplayer2/media3 version strings of this script and exit" + echo " --noclean : Do not call './gradlew clean' in project directory." + echo " -h, --help: show this help text" +} + +function print_pairs { + while read -r line; + do + IFS=' ' read -ra PAIR <<< "$line" + printf "%-55s %-30s\n" "${PAIR[0]}" "${PAIR[1]}" + done <<< "$(echo "$@")" +} + +function print_class_mappings { + while read -r mapping; + do + old=$(echo "$mapping" | cut -d ' ' -f1) + new=$(echo "$mapping" | cut -d ' ' -f2) + classes=$(echo "$mapping" | cut -d ' ' -f3-) + for clazz in $classes; + do + printf "%-80s %-30s\n" "$old.$clazz" "$new.$clazz" + done + done <<< "$(echo "$CLASS_MAPPINGS" | sort)" +} + +ERROR_COUNTER=0 +VALIDATION_ERRORS='' + +function add_validation_error { + let ERROR_COUNTER++ + VALIDATION_ERRORS+="\033[31m[$ERROR_COUNTER] ->\033[0m ${1}" +} + +function validate_exoplayer_version() { + has_exoplayer_dependency='' + while read -r file; + do + local version + version=$(grep -m 1 "com\.google\.android\.exoplayer:" "$file" | cut -d ":" -f3 | tr -d \" | tr -d \') + if [[ ! -z $version ]] && [[ ! "$version" =~ $LEGACY_PEER_VERSION ]]; + then + add_validation_error "The version does not match '$LEGACY_PEER_VERSION'. \ +Update to '$LEGACY_PEER_VERSION' or use the migration script matching your \ +current version. Current version '$version' found in\n $file\n" + fi + done <<< "$(find . -type f -name "build.gradle")" +} + +function validate_string_not_contained { + local pattern=$1 # regex + local failure_message=$2 + while read -r file; + do + if grep -q -e "$pattern" "$file"; + then + add_validation_error "$failure_message:\n $file\n" + fi + done <<< "$files" +} + +function validate_string_patterns { + validate_string_not_contained \ + 'com\.google\.android\.exoplayer2\..*\*' \ + 'Replace wildcard import statements with fully qualified import statements'; + validate_string_not_contained \ + 'com\.google\.android\.exoplayer2\.ui\.PlayerView' \ + 'Migrate PlayerView to StyledPlayerView before migrating'; + validate_string_not_contained \ + 'LegacyPlayerView' \ + 'Migrate LegacyPlayerView to StyledPlayerView before migrating'; + validate_string_not_contained \ + 'com\.google\.android\.exoplayer2\.ext\.mediasession' \ + 'The MediaSessionConnector is integrated in androidx.media3.session.MediaSession' +} + +SED_CMD_INPLACE='sed -i ' +if [[ "$OSTYPE" == "darwin"* ]]; then + SED_CMD_INPLACE="sed -i '' " +fi + +MIGRATE_FILES='1' +LIST_FILES_ONLY='1' +PRINT_CLASS_MAPPINGS='1' +PRINT_PACKAGE_MAPPINGS='1' +PRINT_DEPENDENCY_MAPPINGS='1' +PRINT_VERSION='1' +NO_CLEAN='1' +FORCE='1' +IGNORE_VERSION='1' +EXCLUDED_PATHS='' + +while [[ $1 =~ ^-.* ]]; +do + case "$1" in + -m ) MIGRATE_FILES='';; + -l ) LIST_FILES_ONLY='';; + -c ) PRINT_CLASS_MAPPINGS='';; + -p ) PRINT_PACKAGE_MAPPINGS='';; + -d ) PRINT_DEPENDENCY_MAPPINGS='';; + -v ) PRINT_VERSION='';; + -f ) FORCE='';; + -x ) shift; EXCLUDED_PATHS="$(printf "%s\n%s" $EXCLUDED_PATHS $1)";; + --noclean ) NO_CLEAN='';; + * ) usage && exit 1;; + esac + shift +done + +if [[ -z $PRINT_DEPENDENCY_MAPPINGS ]]; +then + print_pairs "$DEPENDENCY_MAPPINGS" + exit 0 +elif [[ -z $PRINT_PACKAGE_MAPPINGS ]]; +then + print_pairs "$PACKAGE_MAPPINGS" + exit 0 +elif [[ -z $PRINT_CLASS_MAPPINGS ]]; +then + print_class_mappings + exit 0 +elif [[ -z $PRINT_VERSION ]]; +then + echo "$LEGACY_PEER_VERSION -> $MEDIA3_VERSION. This script is written to migrate from ExoPlayer $LEGACY_PEER_VERSION to AndroidX Media3 $MEDIA3_VERSION" + exit 0 +elif [[ -z $1 ]]; +then + usage + exit 1 +fi + +if [[ ! -f $1/gradlew ]]; +then + echo "directory seems not to exist or is not a gradle project (missing 'gradlew')" + usage + exit 1 +fi + +PROJECT_ROOT=$1 +cd "$PROJECT_ROOT" + +# Create the set of files to transform +exclusion="/build/|/.idea/|/res/drawable|/res/color|/res/mipmap|/res/values|" +if [[ ! -z $EXCLUDED_PATHS ]]; +then + while read -r path; + do + exclusion="$exclusion./$path|" + done <<< "$EXCLUDED_PATHS" +fi +files=$(find . -name '*\.java' -o -name '*\.kt' -o -name '*\.xml' | grep -Ev "'$exclusion'") + +# Validate project and exit in case of validation errors +validate_string_patterns +validate_exoplayer_version "$PROJECT_ROOT" +if [[ ! -z $FORCE && ! -z "$VALIDATION_ERRORS" ]]; +then + echo "=============================================" + echo "Validation errors (use -f to force execution)" + echo "---------------------------------------------" + echo -e "$VALIDATION_ERRORS" + exit 1 +fi + +if [[ -z $LIST_FILES_ONLY ]]; +then + echo "$files" | cut -c 3- + find . -type f -name 'build\.gradle' | cut -c 3- + exit 0 +fi + +# start migration after successful validation or when forced to disregard validation +# errors + +if [[ ! -z "$MIGRATE_FILES" ]]; +then + echo "nothing to do" + usage + exit 0 +fi + +PWD=$(pwd) +if [[ ! -z $NO_CLEAN ]]; +then + cd "$PROJECT_ROOT" + ./gradlew clean + cd "$PWD" +fi + +# create expressions for class renamings +renaming_expressions='' +while read -r renaming; +do + src=$(echo "$renaming" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') + dest=$(echo "$renaming" | cut -d ' ' -f2) + renaming_expressions+="-e s/$src/$dest/g " +done <<< "$CLASS_RENAMINGS" + +# create expressions for class mappings +classes_expressions='' +while read -r mapping; +do + src=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') + dest=$(echo "$mapping" | cut -d ' ' -f2) + classes=$(echo "$mapping" | cut -d ' ' -f3-) + for clazz in $classes; + do + classes_expressions+="-e s/$src\.$clazz/$dest.$clazz/g " + done +done <<< "$CLASS_MAPPINGS" + +# create expressions for package mappings +packages_expressions='' +while read -r mapping; +do + src=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') + dest=$(echo "$mapping" | cut -d ' ' -f2) + packages_expressions+="-e s/$src/$dest/g " +done <<< "$PACKAGE_MAPPINGS" + +# do search and replace with expressions in each selected file +while read -r file; +do + echo "migrating $file" + expr="$renaming_expressions $classes_expressions $packages_expressions" + $SED_CMD_INPLACE $expr $file +done <<< "$files" + +# create expressions for dependencies in gradle files +EXOPLAYER_GROUP="com\.google\.android\.exoplayer" +MEDIA3_GROUP="androidx.media3" +dependency_expressions="" +while read -r mapping +do + OLD=$(echo "$mapping" | cut -d ' ' -f1 | sed -e 's/\./\\\./g') + NEW=$(echo "$mapping" | cut -d ' ' -f2) + dependency_expressions="$dependency_expressions -e s/$EXOPLAYER_GROUP:$OLD:.*\"/$MEDIA3_GROUP:$NEW:$MEDIA3_VERSION\"/g -e s/$EXOPLAYER_GROUP:$OLD:.*'/$MEDIA3_GROUP:$NEW:$MEDIA3_VERSION'/" +done <<< "$DEPENDENCY_MAPPINGS" + +## do search and replace for dependencies in gradle files +while read -r build_file; +do + echo "migrating build file $build_file" + $SED_CMD_INPLACE $dependency_expressions $build_file +done <<< "$(find . -type f -name 'build\.gradle')" diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java index b01ff7345fb..acdd0fe8c72 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -101,9 +101,9 @@ public final class CastPlayer extends BasePlayer { COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS, - COMMAND_GET_TRACKS, - COMMAND_SET_MEDIA_ITEM) + COMMAND_GET_TRACKS) .build(); public static final float MIN_SPEED_SUPPORTED = 0.5f; @@ -145,6 +145,7 @@ public final class CastPlayer extends BasePlayer { private int pendingSeekWindowIndex; private long pendingSeekPositionMs; @Nullable private PositionInfo pendingMediaItemRemovalPosition; + private MediaMetadata mediaMetadata; /** * Creates a new cast player. @@ -198,7 +199,7 @@ public CastPlayer( this.mediaItemConverter = mediaItemConverter; this.seekBackIncrementMs = seekBackIncrementMs; this.seekForwardIncrementMs = seekForwardIncrementMs; - timelineTracker = new CastTimelineTracker(); + timelineTracker = new CastTimelineTracker(mediaItemConverter); period = new Timeline.Period(); statusListener = new StatusListener(); seekResultCallback = new SeekResultCallback(); @@ -212,6 +213,7 @@ public CastPlayer( playbackParameters = new StateHolder<>(PlaybackParameters.DEFAULT); playbackState = STATE_IDLE; currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE; + mediaMetadata = MediaMetadata.EMPTY; currentTracks = Tracks.EMPTY; availableCommands = new Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build(); pendingSeekWindowIndex = C.INDEX_UNSET; @@ -283,8 +285,7 @@ public void setMediaItems(List mediaItems, boolean resetPosition) { @Override public void setMediaItems(List mediaItems, int startIndex, long startPositionMs) { - setMediaItemsInternal( - toMediaQueueItems(mediaItems), startIndex, startPositionMs, repeatMode.value); + setMediaItemsInternal(mediaItems, startIndex, startPositionMs, repeatMode.value); } @Override @@ -294,7 +295,7 @@ public void addMediaItems(int index, List mediaItems) { if (index < currentTimeline.getWindowCount()) { uid = (int) currentTimeline.getWindow(/* windowIndex= */ index, window).uid; } - addMediaItemsInternal(toMediaQueueItems(mediaItems), uid); + addMediaItemsInternal(mediaItems, uid); } @Override @@ -426,6 +427,13 @@ public void seekTo(int mediaItemIndex, long positionMs) { Player.EVENT_MEDIA_ITEM_TRANSITION, listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK)); + MediaMetadata oldMediaMetadata = mediaMetadata; + mediaMetadata = getMediaMetadataInternal(); + if (!oldMediaMetadata.equals(mediaMetadata)) { + listeners.queueEvent( + Player.EVENT_MEDIA_METADATA_CHANGED, + listener -> listener.onMediaMetadataChanged(mediaMetadata)); + } } updateAvailableCommandsAndNotifyIfChanged(); } else if (pendingSeekCount == 0) { @@ -563,8 +571,12 @@ public void setTrackSelectionParameters(TrackSelectionParameters parameters) {} @Override public MediaMetadata getMediaMetadata() { - // CastPlayer does not currently support metadata. - return MediaMetadata.EMPTY; + return mediaMetadata; + } + + public MediaMetadata getMediaMetadataInternal() { + MediaItem currentMediaItem = getCurrentMediaItem(); + return currentMediaItem != null ? currentMediaItem.mediaMetadata : MediaMetadata.EMPTY; } @Override @@ -761,6 +773,7 @@ private void updateInternalStateAndNotifyIfChanged() { return; } int oldWindowIndex = this.currentWindowIndex; + MediaMetadata oldMediaMetadata = mediaMetadata; @Nullable Object oldPeriodUid = !getCurrentTimeline().isEmpty() @@ -772,6 +785,7 @@ private void updateInternalStateAndNotifyIfChanged() { boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged(); Timeline currentTimeline = getCurrentTimeline(); currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline); + mediaMetadata = getMediaMetadataInternal(); @Nullable Object currentPeriodUid = !currentTimeline.isEmpty() @@ -825,6 +839,11 @@ private void updateInternalStateAndNotifyIfChanged() { listeners.queueEvent( Player.EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(currentTracks)); } + if (!oldMediaMetadata.equals(mediaMetadata)) { + listeners.queueEvent( + Player.EVENT_MEDIA_METADATA_CHANGED, + listener -> listener.onMediaMetadataChanged(mediaMetadata)); + } updateAvailableCommandsAndNotifyIfChanged(); listeners.flushEvents(); } @@ -1020,14 +1039,13 @@ private void updateAvailableCommandsAndNotifyIfChanged() { } } - @Nullable - private PendingResult setMediaItemsInternal( - MediaQueueItem[] mediaQueueItems, + private void setMediaItemsInternal( + List mediaItems, int startIndex, long startPositionMs, @RepeatMode int repeatMode) { - if (remoteMediaClient == null || mediaQueueItems.length == 0) { - return null; + if (remoteMediaClient == null || mediaItems.isEmpty()) { + return; } startPositionMs = startPositionMs == C.TIME_UNSET ? 0 : startPositionMs; if (startIndex == C.INDEX_UNSET) { @@ -1038,34 +1056,35 @@ private PendingResult setMediaItemsInternal( if (!currentTimeline.isEmpty()) { pendingMediaItemRemovalPosition = getCurrentPositionInfo(); } - return remoteMediaClient.queueLoad( + MediaQueueItem[] mediaQueueItems = toMediaQueueItems(mediaItems); + timelineTracker.onMediaItemsSet(mediaItems, mediaQueueItems); + remoteMediaClient.queueLoad( mediaQueueItems, - min(startIndex, mediaQueueItems.length - 1), + min(startIndex, mediaItems.size() - 1), getCastRepeatMode(repeatMode), startPositionMs, /* customData= */ null); } - @Nullable - private PendingResult addMediaItemsInternal(MediaQueueItem[] items, int uid) { + private void addMediaItemsInternal(List mediaItems, int uid) { if (remoteMediaClient == null || getMediaStatus() == null) { - return null; + return; } - return remoteMediaClient.queueInsertItems(items, uid, /* customData= */ null); + MediaQueueItem[] itemsToInsert = toMediaQueueItems(mediaItems); + timelineTracker.onMediaItemsAdded(mediaItems, itemsToInsert); + remoteMediaClient.queueInsertItems(itemsToInsert, uid, /* customData= */ null); } - @Nullable - private PendingResult moveMediaItemsInternal( - int[] uids, int fromIndex, int newIndex) { + private void moveMediaItemsInternal(int[] uids, int fromIndex, int newIndex) { if (remoteMediaClient == null || getMediaStatus() == null) { - return null; + return; } int insertBeforeIndex = fromIndex < newIndex ? newIndex + uids.length : newIndex; int insertBeforeItemId = MediaQueueItem.INVALID_ITEM_ID; if (insertBeforeIndex < currentTimeline.getWindowCount()) { insertBeforeItemId = (int) currentTimeline.getWindow(insertBeforeIndex, window).uid; } - return remoteMediaClient.queueReorderItems(uids, insertBeforeItemId, /* customData= */ null); + remoteMediaClient.queueReorderItems(uids, insertBeforeItemId, /* customData= */ null); } @Nullable diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java b/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java index 12e8ee5d2db..7cf90c5a4dc 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java @@ -15,13 +15,13 @@ */ package androidx.media3.cast; -import android.net.Uri; import android.util.SparseArray; import android.util.SparseIntArray; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.Timeline; +import com.google.android.gms.cast.MediaInfo; import java.util.Arrays; /** A {@link Timeline} for Cast media queues. */ @@ -30,12 +30,16 @@ /** Holds {@link Timeline} related data for a Cast media item. */ public static final class ItemData { + /* package */ static final String UNKNOWN_CONTENT_ID = "UNKNOWN_CONTENT_ID"; + /** Holds no media information. */ public static final ItemData EMPTY = new ItemData( /* durationUs= */ C.TIME_UNSET, /* defaultPositionUs= */ C.TIME_UNSET, - /* isLive= */ false); + /* isLive= */ false, + MediaItem.EMPTY, + UNKNOWN_CONTENT_ID); /** The duration of the item in microseconds, or {@link C#TIME_UNSET} if unknown. */ public final long durationUs; @@ -45,6 +49,10 @@ public static final class ItemData { public final long defaultPositionUs; /** Whether the item is live content, or {@code false} if unknown. */ public final boolean isLive; + /** The original media item that has been set or added to the playlist. */ + public final MediaItem mediaItem; + /** The {@linkplain MediaInfo#getContentId() content ID} of the cast media queue item. */ + public final String contentId; /** * Creates an instance. @@ -52,11 +60,20 @@ public static final class ItemData { * @param durationUs See {@link #durationsUs}. * @param defaultPositionUs See {@link #defaultPositionUs}. * @param isLive See {@link #isLive}. + * @param mediaItem See {@link #mediaItem}. + * @param contentId See {@link #contentId}. */ - public ItemData(long durationUs, long defaultPositionUs, boolean isLive) { + public ItemData( + long durationUs, + long defaultPositionUs, + boolean isLive, + MediaItem mediaItem, + String contentId) { this.durationUs = durationUs; this.defaultPositionUs = defaultPositionUs; this.isLive = isLive; + this.mediaItem = mediaItem; + this.contentId = contentId; } /** @@ -66,14 +83,23 @@ public ItemData(long durationUs, long defaultPositionUs, boolean isLive) { * @param defaultPositionUs The default start position in microseconds, or {@link C#TIME_UNSET} * if unknown. * @param isLive Whether the item is live, or {@code false} if unknown. + * @param mediaItem The media item. + * @param contentId The content ID. */ - public ItemData copyWithNewValues(long durationUs, long defaultPositionUs, boolean isLive) { + public ItemData copyWithNewValues( + long durationUs, + long defaultPositionUs, + boolean isLive, + MediaItem mediaItem, + String contentId) { if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs - && isLive == this.isLive) { + && isLive == this.isLive + && contentId.equals(this.contentId) + && mediaItem.equals(this.mediaItem)) { return this; } - return new ItemData(durationUs, defaultPositionUs, isLive); + return new ItemData(durationUs, defaultPositionUs, isLive, mediaItem, contentId); } } @@ -82,6 +108,7 @@ public ItemData copyWithNewValues(long durationUs, long defaultPositionUs, boole new CastTimeline(new int[0], new SparseArray<>()); private final SparseIntArray idsToIndex; + private final MediaItem[] mediaItems; private final int[] ids; private final long[] durationsUs; private final long[] defaultPositionsUs; @@ -100,10 +127,12 @@ public CastTimeline(int[] itemIds, SparseArray itemIdToData) { durationsUs = new long[itemCount]; defaultPositionsUs = new long[itemCount]; isLive = new boolean[itemCount]; + mediaItems = new MediaItem[itemCount]; for (int i = 0; i < ids.length; i++) { int id = ids[i]; idsToIndex.put(id, i); ItemData data = itemIdToData.get(id, ItemData.EMPTY); + mediaItems[i] = data.mediaItem; durationsUs[i] = data.durationUs; defaultPositionsUs[i] = data.defaultPositionUs == C.TIME_UNSET ? 0 : data.defaultPositionUs; isLive[i] = data.isLive; @@ -121,18 +150,16 @@ public int getWindowCount() { public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { long durationUs = durationsUs[windowIndex]; boolean isDynamic = durationUs == C.TIME_UNSET; - MediaItem mediaItem = - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build(); return window.set( /* uid= */ ids[windowIndex], - /* mediaItem= */ mediaItem, + /* mediaItem= */ mediaItems[windowIndex], /* manifest= */ null, /* presentationStartTimeMs= */ C.TIME_UNSET, /* windowStartTimeMs= */ C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, /* isSeekable= */ !isDynamic, isDynamic, - isLive[windowIndex] ? mediaItem.liveConfiguration : null, + isLive[windowIndex] ? mediaItems[windowIndex].liveConfiguration : null, defaultPositionsUs[windowIndex], durationUs, /* firstPeriodIndex= */ windowIndex, diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastTimelineTracker.java b/libraries/cast/src/main/java/androidx/media3/cast/CastTimelineTracker.java index e1234951520..c955387ff42 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastTimelineTracker.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastTimelineTracker.java @@ -15,14 +15,23 @@ */ package androidx.media3.cast; +import static androidx.media3.cast.CastTimeline.ItemData.UNKNOWN_CONTENT_ID; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkStateNotNull; + import android.util.SparseArray; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaQueueItem; import com.google.android.gms.cast.MediaStatus; import com.google.android.gms.cast.framework.media.RemoteMediaClient; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; /** * Creates {@link CastTimeline CastTimelines} from cast receiver app status updates. @@ -33,9 +42,47 @@ /* package */ final class CastTimelineTracker { private final SparseArray itemIdToData; + private final MediaItemConverter mediaItemConverter; + @VisibleForTesting /* package */ final HashMap mediaItemsByContentId; - public CastTimelineTracker() { + /** + * Creates an instance. + * + * @param mediaItemConverter The converter used to convert from a {@link MediaQueueItem} to a + * {@link MediaItem}. + */ + public CastTimelineTracker(MediaItemConverter mediaItemConverter) { + this.mediaItemConverter = mediaItemConverter; itemIdToData = new SparseArray<>(); + mediaItemsByContentId = new HashMap<>(); + } + + /** + * Called when media items {@linkplain Player#setMediaItems have been set to the playlist} and are + * sent to the cast playback queue. A future queue update of the {@link RemoteMediaClient} will + * reflect this addition. + * + * @param mediaItems The media items that have been set. + * @param mediaQueueItems The corresponding media queue items. + */ + public void onMediaItemsSet(List mediaItems, MediaQueueItem[] mediaQueueItems) { + mediaItemsByContentId.clear(); + onMediaItemsAdded(mediaItems, mediaQueueItems); + } + + /** + * Called when media items {@linkplain Player#addMediaItems(List) have been added} and are sent to + * the cast playback queue. A future queue update of the {@link RemoteMediaClient} will reflect + * this addition. + * + * @param mediaItems The media items that have been added. + * @param mediaQueueItems The corresponding media queue items. + */ + public void onMediaItemsAdded(List mediaItems, MediaQueueItem[] mediaQueueItems) { + for (int i = 0; i < mediaItems.size(); i++) { + mediaItemsByContentId.put( + checkNotNull(mediaQueueItems[i].getMedia()).getContentId(), mediaItems.get(i)); + } } /** @@ -63,18 +110,36 @@ public CastTimeline getCastTimeline(RemoteMediaClient remoteMediaClient) { } int currentItemId = mediaStatus.getCurrentItemId(); + String currentContentId = checkStateNotNull(mediaStatus.getMediaInfo()).getContentId(); + MediaItem mediaItem = mediaItemsByContentId.get(currentContentId); updateItemData( - currentItemId, mediaStatus.getMediaInfo(), /* defaultPositionUs= */ C.TIME_UNSET); + currentItemId, + mediaItem != null ? mediaItem : MediaItem.EMPTY, + mediaStatus.getMediaInfo(), + currentContentId, + /* defaultPositionUs= */ C.TIME_UNSET); - for (MediaQueueItem item : mediaStatus.getQueueItems()) { - long defaultPositionUs = (long) (item.getStartTime() * C.MICROS_PER_SECOND); - updateItemData(item.getItemId(), item.getMedia(), defaultPositionUs); + for (MediaQueueItem queueItem : mediaStatus.getQueueItems()) { + long defaultPositionUs = (long) (queueItem.getStartTime() * C.MICROS_PER_SECOND); + @Nullable MediaInfo mediaInfo = queueItem.getMedia(); + String contentId = mediaInfo != null ? mediaInfo.getContentId() : UNKNOWN_CONTENT_ID; + mediaItem = mediaItemsByContentId.get(contentId); + updateItemData( + queueItem.getItemId(), + mediaItem != null ? mediaItem : mediaItemConverter.toMediaItem(queueItem), + mediaInfo, + contentId, + defaultPositionUs); } - return new CastTimeline(itemIds, itemIdToData); } - private void updateItemData(int itemId, @Nullable MediaInfo mediaInfo, long defaultPositionUs) { + private void updateItemData( + int itemId, + MediaItem mediaItem, + @Nullable MediaInfo mediaInfo, + String contentId, + long defaultPositionUs) { CastTimeline.ItemData previousData = itemIdToData.get(itemId, CastTimeline.ItemData.EMPTY); long durationUs = CastUtils.getStreamDurationUs(mediaInfo); if (durationUs == C.TIME_UNSET) { @@ -87,7 +152,10 @@ private void updateItemData(int itemId, @Nullable MediaInfo mediaInfo, long defa if (defaultPositionUs == C.TIME_UNSET) { defaultPositionUs = previousData.defaultPositionUs; } - itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive)); + itemIdToData.put( + itemId, + previousData.copyWithNewValues( + durationUs, defaultPositionUs, isLive, mediaItem, contentId)); } private void removeUnusedItemDataEntries(int[] itemIds) { @@ -99,6 +167,8 @@ private void removeUnusedItemDataEntries(int[] itemIds) { int index = 0; while (index < itemIdToData.size()) { if (!scratchItemIds.contains(itemIdToData.keyAt(index))) { + CastTimeline.ItemData itemData = itemIdToData.valueAt(index); + mediaItemsByContentId.remove(itemData.contentId); itemIdToData.removeAt(index); } else { index++; diff --git a/libraries/cast/src/main/java/androidx/media3/cast/DefaultMediaItemConverter.java b/libraries/cast/src/main/java/androidx/media3/cast/DefaultMediaItemConverter.java index d4bcbd4b9d1..97b90b2b4b5 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/DefaultMediaItemConverter.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/DefaultMediaItemConverter.java @@ -128,11 +128,14 @@ public MediaQueueItem toMediaQueueItem(MediaItem mediaItem) { if (mediaItem.mediaMetadata.trackNumber != null) { metadata.putInt(MediaMetadata.KEY_TRACK_NUMBER, mediaItem.mediaMetadata.trackNumber); } - + String contentUrl = mediaItem.localConfiguration.uri.toString(); + String contentId = + mediaItem.mediaId.equals(MediaItem.DEFAULT_MEDIA_ID) ? contentUrl : mediaItem.mediaId; MediaInfo mediaInfo = - new MediaInfo.Builder(mediaItem.localConfiguration.uri.toString()) + new MediaInfo.Builder(contentId) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setContentType(mediaItem.localConfiguration.mimeType) + .setContentUrl(contentUrl) .setMetadata(metadata) .setCustomData(getCustomData(mediaItem)) .build(); diff --git a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java index 31b7afd87ad..0462878afa5 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -63,9 +63,11 @@ import android.net.Uri; import androidx.media3.common.C; import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; import androidx.media3.common.MimeTypes; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; +import androidx.media3.common.Player.Listener; import androidx.media3.common.Timeline; import androidx.media3.common.util.Assertions; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -98,6 +100,7 @@ public class CastPlayerTest { private CastPlayer castPlayer; + private DefaultMediaItemConverter mediaItemConverter; private RemoteMediaClient.Callback remoteMediaClientCallback; @Mock private RemoteMediaClient mockRemoteMediaClient; @@ -106,7 +109,7 @@ public class CastPlayerTest { @Mock private CastContext mockCastContext; @Mock private SessionManager mockSessionManager; @Mock private CastSession mockCastSession; - @Mock private Player.Listener mockListener; + @Mock private Listener mockListener; @Mock private PendingResult mockPendingResult; @Captor @@ -126,12 +129,14 @@ public void setUp() { when(mockCastSession.getRemoteMediaClient()).thenReturn(mockRemoteMediaClient); when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus); when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue); + when(mockMediaStatus.getMediaInfo()).thenReturn(new MediaInfo.Builder("contentId").build()); when(mockMediaQueue.getItemIds()).thenReturn(new int[0]); // Make the remote media client present the same default values as ExoPlayer: when(mockRemoteMediaClient.isPaused()).thenReturn(true); when(mockMediaStatus.getQueueRepeatMode()).thenReturn(MediaStatus.REPEAT_MODE_REPEAT_OFF); when(mockMediaStatus.getPlaybackRate()).thenReturn(1.0d); - castPlayer = new CastPlayer(mockCastContext); + mediaItemConverter = new DefaultMediaItemConverter(); + castPlayer = new CastPlayer(mockCastContext, mediaItemConverter); castPlayer.addListener(mockListener); verify(mockRemoteMediaClient).registerCallback(callbackArgumentCaptor.capture()); remoteMediaClientCallback = callbackArgumentCaptor.getValue(); @@ -388,7 +393,7 @@ public void setMediaItems_callsRemoteMediaClient() { mediaItems.add( new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); - castPlayer.setMediaItems(mediaItems, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + castPlayer.setMediaItems(mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 2000L); verify(mockRemoteMediaClient) .queueLoad(queueItemsArgumentCaptor.capture(), eq(1), anyInt(), eq(2000L), any()); @@ -434,22 +439,23 @@ public void setMediaItems_replaceExistingPlaylist_notifiesMediaItemTransition() .setMimeType(MimeTypes.APPLICATION_MPD) .build()); - castPlayer.setMediaItems( - firstPlaylist, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 2000L); updateTimeLine( firstPlaylist, /* mediaQueueItemIds= */ new int[] {1, 2}, /* currentItemId= */ 2); // Replacing existing playlist. - castPlayer.setMediaItems( - secondPlaylist, /* startWindowIndex= */ 0, /* startPositionMs= */ 1000L); + castPlayer.setMediaItems(secondPlaylist, /* startIndex= */ 0, /* startPositionMs= */ 1000L); updateTimeLine(secondPlaylist, /* mediaQueueItemIds= */ new int[] {3}, /* currentItemId= */ 3); InOrder inOrder = Mockito.inOrder(mockListener); inOrder - .verify(mockListener, times(2)) + .verify(mockListener) + .onMediaItemTransition( + eq(firstPlaylist.get(1)), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockListener) .onMediaItemTransition( - mediaItemCaptor.capture(), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + eq(secondPlaylist.get(0)), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); - assertThat(mediaItemCaptor.getAllValues().get(1).localConfiguration.tag).isEqualTo(3); } @SuppressWarnings("deprecation") // Verifies deprecated callback being called correctly. @@ -469,8 +475,7 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( .setMimeType(MimeTypes.APPLICATION_MPD) .build()); - castPlayer.setMediaItems( - firstPlaylist, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 2000L); updateTimeLine( firstPlaylist, /* mediaQueueItemIds= */ new int[] {1, 2}, @@ -481,8 +486,7 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( /* durationsMs= */ new long[] {20_000, 20_000}, /* positionMs= */ 2000L); // Replacing existing playlist. - castPlayer.setMediaItems( - secondPlaylist, /* startWindowIndex= */ 0, /* startPositionMs= */ 1000L); + castPlayer.setMediaItems(secondPlaylist, /* startIndex= */ 0, /* startPositionMs= */ 1000L); updateTimeLine( secondPlaylist, /* mediaQueueItemIds= */ new int[] {3}, @@ -494,8 +498,8 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 2, - /* windowIndex= */ 1, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(2).build(), + /* mediaItemIndex= */ 1, + firstPlaylist.get(1), /* periodUid= */ 2, /* periodIndex= */ 1, /* positionMs= */ 2000, @@ -505,8 +509,8 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 3, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(3).build(), + /* mediaItemIndex= */ 0, + secondPlaylist.get(0), /* periodUid= */ 3, /* periodIndex= */ 0, /* positionMs= */ 1000, @@ -536,34 +540,37 @@ public void addMediaItems_callsRemoteMediaClient() { verify(mockRemoteMediaClient) .queueInsertItems( queueItemsArgumentCaptor.capture(), eq(MediaQueueItem.INVALID_ITEM_ID), any()); - MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri1); assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2); } - @SuppressWarnings("ConstantConditions") @Test public void addMediaItems_insertAtIndex_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2); List mediaItems = createMediaItems(mediaQueueItemIds); + // Add two items. addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); String uri = "http://www.google.com/video3"; MediaItem anotherMediaItem = new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(); - - // Add another on position 1 int index = 1; - castPlayer.addMediaItems(index, Collections.singletonList(anotherMediaItem)); + List newPlaylist = Collections.singletonList(anotherMediaItem); - verify(mockRemoteMediaClient) - .queueInsertItems( - queueItemsArgumentCaptor.capture(), - eq((int) mediaItems.get(index).localConfiguration.tag), - any()); + // Add another on position 1 + castPlayer.addMediaItems(index, newPlaylist); + updateTimeLine(newPlaylist, /* mediaQueueItemIds= */ new int[] {123}, /* currentItemId= */ 1); - MediaQueueItem[] mediaQueueItems = queueItemsArgumentCaptor.getValue(); - assertThat(mediaQueueItems[0].getMedia().getContentId()).isEqualTo(uri); + verify(mockRemoteMediaClient, times(2)) + .queueInsertItems(queueItemsArgumentCaptor.capture(), anyInt(), any()); + assertThat(queueItemsArgumentCaptor.getAllValues().get(1)[0]) + .isEqualTo(mediaItemConverter.toMediaQueueItem(anotherMediaItem)); + Timeline.Window currentWindow = + castPlayer + .getCurrentTimeline() + .getWindow(castPlayer.getCurrentMediaItemIndex(), new Timeline.Window()); + assertThat(currentWindow.uid).isEqualTo(123); + assertThat(currentWindow.mediaItem).isEqualTo(anotherMediaItem); } @Test @@ -702,8 +709,8 @@ public void addMediaItems_fillsTimeline() { Timeline currentTimeline = castPlayer.getCurrentTimeline(); for (int i = 0; i < mediaItems.size(); i++) { - assertThat(currentTimeline.getWindow(/* windowIndex= */ i, window).uid) - .isEqualTo(mediaItems.get(i).localConfiguration.tag); + assertThat(currentTimeline.getWindow(/* windowIndex= */ i, window).mediaItem) + .isEqualTo(mediaItems.get(i)); } } @@ -720,10 +727,8 @@ public void addMediaItems_notifiesMediaItemTransition() { inOrder .verify(mockListener) .onMediaItemTransition( - mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + eq(mediaItem), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); - assertThat(mediaItemCaptor.getValue().localConfiguration.tag) - .isEqualTo(mediaItem.localConfiguration.tag); } @Test @@ -742,7 +747,8 @@ public void clearMediaItems_notifiesMediaItemTransition() { InOrder inOrder = Mockito.inOrder(mockListener); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + .onMediaItemTransition( + eq(mediaItems.get(0)), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder .verify(mockListener) .onMediaItemTransition( @@ -776,8 +782,8 @@ public void clearMediaItems_notifiesPositionDiscontinuity() { Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(), + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 1234, @@ -787,7 +793,7 @@ public void clearMediaItems_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ null, - /* windowIndex= */ 0, + /* mediaItemIndex= */ 0, /* mediaItem= */ null, /* periodUid= */ null, /* periodIndex= */ 0, @@ -827,10 +833,8 @@ public void removeCurrentMediaItem_notifiesMediaItemTransition() { .onMediaItemTransition( mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); - assertThat(mediaItemCaptor.getAllValues().get(0).localConfiguration.tag) - .isEqualTo(mediaItem1.localConfiguration.tag); - assertThat(mediaItemCaptor.getAllValues().get(1).localConfiguration.tag) - .isEqualTo(mediaItem2.localConfiguration.tag); + assertThat(mediaItemCaptor.getAllValues().get(0)).isEqualTo(mediaItem1); + assertThat(mediaItemCaptor.getAllValues().get(1)).isEqualTo(mediaItem2); } @Test @@ -862,8 +866,8 @@ public void removeCurrentMediaItem_notifiesPositionDiscontinuity() { Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(), + /* mediaItemIndex= */ 0, + mediaItem1, /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 1234, @@ -873,8 +877,8 @@ public void removeCurrentMediaItem_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 2, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(2).build(), + /* mediaItemIndex= */ 0, + mediaItem2, /* periodUid= */ 2, /* periodIndex= */ 0, /* positionMs= */ 0, @@ -912,10 +916,8 @@ public void removeCurrentMediaItem_byRemoteClient_notifiesMediaItemTransition() mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); List capturedMediaItems = mediaItemCaptor.getAllValues(); - assertThat(capturedMediaItems.get(0).localConfiguration.tag) - .isEqualTo(mediaItem1.localConfiguration.tag); - assertThat(capturedMediaItems.get(1).localConfiguration.tag) - .isEqualTo(mediaItem2.localConfiguration.tag); + assertThat(capturedMediaItems.get(0)).isEqualTo(mediaItem1); + assertThat(capturedMediaItems.get(1)).isEqualTo(mediaItem2); } @Test @@ -945,8 +947,8 @@ public void removeCurrentMediaItem_byRemoteClient_notifiesPositionDiscontinuity( Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(), + /* mediaItemIndex= */ 0, + mediaItem1, /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 0, // position at which we receive the timeline change @@ -956,8 +958,8 @@ public void removeCurrentMediaItem_byRemoteClient_notifiesPositionDiscontinuity( Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 2, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(2).build(), + /* mediaItemIndex= */ 0, + mediaItem2, /* periodUid= */ 2, /* periodIndex= */ 0, /* positionMs= */ 0, @@ -992,7 +994,8 @@ public void removeNonCurrentMediaItem_doesNotNotifyMediaItemTransition() { InOrder inOrder = Mockito.inOrder(mockListener); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + .onMediaItemTransition( + eq(mediaItem1), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); } @@ -1027,19 +1030,21 @@ public void seekTo_otherWindow_notifiesMediaItemTransition() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1234); + MediaMetadata firstMediaMetadata = castPlayer.getMediaMetadata(); + castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 1234); + MediaMetadata secondMediaMetadata = castPlayer.getMediaMetadata(); InOrder inOrder = Mockito.inOrder(mockListener); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + .onMediaItemTransition( + eq(mediaItem1), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder .verify(mockListener) - .onMediaItemTransition( - mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK)); + .onMediaItemTransition(eq(mediaItem2), eq(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK)); inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); - assertThat(mediaItemCaptor.getValue().localConfiguration.tag) - .isEqualTo(mediaItem2.localConfiguration.tag); + assertThat(firstMediaMetadata).isEqualTo(mediaItem1.mediaMetadata); + assertThat(secondMediaMetadata).isEqualTo(mediaItem2.mediaMetadata); } @Test @@ -1054,13 +1059,13 @@ public void seekTo_otherWindow_notifiesPositionDiscontinuity() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1234); + castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 1234); Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(), + /* mediaItemIndex= */ 0, + mediaItem1, /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 0, @@ -1070,8 +1075,8 @@ public void seekTo_otherWindow_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 2, - /* windowIndex= */ 1, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(2).build(), + /* mediaItemIndex= */ 1, + mediaItem2, /* periodUid= */ 2, /* periodIndex= */ 1, /* positionMs= */ 1234, @@ -1097,12 +1102,13 @@ public void seekTo_sameWindow_doesNotNotifyMediaItemTransition() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 1234); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 1234); InOrder inOrder = Mockito.inOrder(mockListener); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + .onMediaItemTransition( + eq(mediaItems.get(0)), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); } @@ -1115,14 +1121,13 @@ public void seekTo_sameWindow_notifiesPositionDiscontinuity() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 1234); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 1234); - MediaItem mediaItem = new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(); Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 0, @@ -1132,8 +1137,8 @@ public void seekTo_sameWindow_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 1234, @@ -1164,13 +1169,12 @@ public void autoTransition_notifiesMediaItemTransition() { InOrder inOrder = Mockito.inOrder(mockListener); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + .onMediaItemTransition( + eq(mediaItems.get(0)), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder .verify(mockListener) - .onMediaItemTransition( - mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)); + .onMediaItemTransition(eq(mediaItems.get(1)), eq(Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)); inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); - assertThat(mediaItemCaptor.getValue().localConfiguration.tag).isEqualTo(2); } @Test @@ -1203,8 +1207,8 @@ public void autoTransition_notifiesPositionDiscontinuity() { Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(), + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 12500, @@ -1214,8 +1218,8 @@ public void autoTransition_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 2, - /* windowIndex= */ 1, - new MediaItem.Builder().setUri(Uri.EMPTY).setTag(2).build(), + /* mediaItemIndex= */ 1, + mediaItems.get(1), /* periodUid= */ 2, /* periodIndex= */ 1, /* positionMs= */ 0, @@ -1250,12 +1254,11 @@ public void seekBack_notifiesPositionDiscontinuity() { mediaItems, mediaQueueItemIds, currentItemId, streamTypes, durationsMs, positionMs); castPlayer.seekBack(); - MediaItem mediaItem = new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(); Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 2 * C.DEFAULT_SEEK_BACK_INCREMENT_MS, @@ -1265,8 +1268,8 @@ public void seekBack_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ C.DEFAULT_SEEK_BACK_INCREMENT_MS, @@ -1299,12 +1302,11 @@ public void seekForward_notifiesPositionDiscontinuity() { mediaItems, mediaQueueItemIds, currentItemId, streamTypes, durationsMs, positionMs); castPlayer.seekForward(); - MediaItem mediaItem = new MediaItem.Builder().setUri(Uri.EMPTY).setTag(1).build(); Player.PositionInfo oldPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ 0, @@ -1314,8 +1316,8 @@ public void seekForward_notifiesPositionDiscontinuity() { Player.PositionInfo newPosition = new Player.PositionInfo( /* windowUid= */ 1, - /* windowIndex= */ 0, - mediaItem, + /* mediaItemIndex= */ 0, + mediaItems.get(0), /* periodUid= */ 1, /* periodIndex= */ 0, /* positionMs= */ C.DEFAULT_SEEK_FORWARD_INCREMENT_MS, @@ -1475,14 +1477,14 @@ public void seekTo_nextWindow_notifiesAvailableCommandsChanged() { // Check that there were no other calls to onAvailableCommandsChanged. verify(mockListener).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 0); verify(mockListener).onAvailableCommandsChanged(commandsWithSeekToPreviousAndNextWindow); verify(mockListener, times(2)).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 2, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 2, /* positionMs= */ 0); verify(mockListener, times(2)).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 3, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 3, /* positionMs= */ 0); verify(mockListener).onAvailableCommandsChanged(commandsWithSeekToPreviousWindow); verify(mockListener, times(3)).onAvailableCommandsChanged(any()); } @@ -1509,14 +1511,14 @@ public void seekTo_previousWindow_notifiesAvailableCommandsChanged() { // Check that there were no other calls to onAvailableCommandsChanged. verify(mockListener).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 2, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 2, /* positionMs= */ 0); verify(mockListener).onAvailableCommandsChanged(commandsWithSeekToPreviousAndNextWindow); verify(mockListener, times(2)).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 0); verify(mockListener, times(2)).onAvailableCommandsChanged(any()); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 0); verify(mockListener).onAvailableCommandsChanged(commandsWithSeekToNextWindow); verify(mockListener, times(3)).onAvailableCommandsChanged(any()); } @@ -1533,8 +1535,8 @@ public void seekTo_sameWindow_doesNotNotifyAvailableCommandsChanged() { updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); verify(mockListener).onAvailableCommandsChanged(defaultCommands); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 200); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 100); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 200); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 100); // Check that there were no other calls to onAvailableCommandsChanged. verify(mockListener).onAvailableCommandsChanged(any()); } @@ -1763,6 +1765,105 @@ public void setRepeatMode_one_doesNotNotifyAvailableCommandsChanged() { verify(mockListener).onAvailableCommandsChanged(any()); } + @Test + public void setMediaItems_doesNotifyOnMetadataChanged() { + when(mockRemoteMediaClient.queueJumpToItem(anyInt(), anyLong(), eq(null))) + .thenReturn(mockPendingResult); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(MediaMetadata.class); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + ImmutableList firstPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(uri1) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setMediaMetadata(new MediaMetadata.Builder().setArtist("foo").build()) + .build()); + ImmutableList secondPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMediaMetadata(new MediaMetadata.Builder().setArtist("bar").build()) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(), + new MediaItem.Builder() + .setUri(uri2) + .setMimeType(MimeTypes.APPLICATION_MP4) + .setMediaMetadata(new MediaMetadata.Builder().setArtist("foobar").build()) + .build()); + castPlayer.addListener(mockListener); + + MediaMetadata intitalMetadata = castPlayer.getMediaMetadata(); + castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 0, /* startPositionMs= */ 2000L); + updateTimeLine(firstPlaylist, /* mediaQueueItemIds= */ new int[] {1}, /* currentItemId= */ 1); + MediaMetadata firstMetadata = castPlayer.getMediaMetadata(); + // Replacing existing playlist. + castPlayer.setMediaItems(secondPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 0L); + updateTimeLine( + secondPlaylist, /* mediaQueueItemIds= */ new int[] {2, 3}, /* currentItemId= */ 3); + MediaMetadata secondMetadata = castPlayer.getMediaMetadata(); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 0); + MediaMetadata thirdMetadata = castPlayer.getMediaMetadata(); + + verify(mockListener, times(3)).onMediaItemTransition(mediaItemCaptor.capture(), anyInt()); + assertThat(mediaItemCaptor.getAllValues()) + .containsExactly(firstPlaylist.get(0), secondPlaylist.get(1), secondPlaylist.get(0)) + .inOrder(); + verify(mockListener, times(3)).onMediaMetadataChanged(metadataCaptor.capture()); + assertThat(metadataCaptor.getAllValues()) + .containsExactly( + firstPlaylist.get(0).mediaMetadata, + secondPlaylist.get(1).mediaMetadata, + secondPlaylist.get(0).mediaMetadata) + .inOrder(); + assertThat(intitalMetadata).isEqualTo(MediaMetadata.EMPTY); + assertThat(ImmutableList.of(firstMetadata, secondMetadata, thirdMetadata)) + .containsExactly( + firstPlaylist.get(0).mediaMetadata, + secondPlaylist.get(1).mediaMetadata, + secondPlaylist.get(0).mediaMetadata) + .inOrder(); + } + + @Test + public void setMediaItems_equalMetadata_doesNotNotifyOnMediaMetadataChanged() { + when(mockRemoteMediaClient.queueJumpToItem(anyInt(), anyLong(), eq(null))) + .thenReturn(mockPendingResult); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + ImmutableList firstPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(uri1) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(1) + .build()); + ImmutableList secondPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setMediaMetadata(MediaMetadata.EMPTY) + .setUri(Uri.EMPTY) + .setTag(2) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(), + new MediaItem.Builder() + .setUri(uri2) + .setMimeType(MimeTypes.APPLICATION_MP4) + .setTag(3) + .build()); + castPlayer.addListener(mockListener); + + castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 0, /* startPositionMs= */ 2000L); + updateTimeLine(firstPlaylist, /* mediaQueueItemIds= */ new int[] {1}, /* currentItemId= */ 1); + castPlayer.setMediaItems(secondPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 0L); + updateTimeLine( + secondPlaylist, /* mediaQueueItemIds= */ new int[] {2, 3}, /* currentItemId= */ 3); + castPlayer.seekTo(/* mediaItemIndex= */ 0, /* positionMs= */ 0); + + verify(mockListener, times(3)).onMediaItemTransition(any(), anyInt()); + verify(mockListener, never()).onMediaMetadataChanged(any()); + } + private int[] createMediaQueueItemIds(int numberOfIds) { int[] mediaQueueItemIds = new int[numberOfIds]; for (int i = 0; i < numberOfIds; i++) { @@ -1782,8 +1883,9 @@ private List createMediaItems(int[] mediaQueueItemIds) { private MediaItem createMediaItem(int mediaQueueItemId) { return new MediaItem.Builder() .setUri("http://www.google.com/video" + mediaQueueItemId) + .setMediaMetadata( + new MediaMetadata.Builder().setArtist("Foo Bar - " + mediaQueueItemId).build()) .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(mediaQueueItemId) .build(); } @@ -1821,8 +1923,12 @@ private void updateTimeLine( int mediaQueueItemId = mediaQueueItemIds[i]; int streamType = streamTypes[i]; long durationMs = durationsMs[i]; + String contentId = + mediaItem.mediaId.equals(MediaItem.DEFAULT_MEDIA_ID) + ? mediaItem.localConfiguration.uri.toString() + : mediaItem.mediaId; MediaInfo.Builder mediaInfoBuilder = - new MediaInfo.Builder(mediaItem.localConfiguration.uri.toString()) + new MediaInfo.Builder(contentId) .setStreamType(streamType) .setContentType(mediaItem.localConfiguration.mimeType); if (durationMs != C.TIME_UNSET) { diff --git a/libraries/cast/src/test/java/androidx/media3/cast/CastTimelineTrackerTest.java b/libraries/cast/src/test/java/androidx/media3/cast/CastTimelineTrackerTest.java index 20fe12ac455..42747462a83 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastTimelineTrackerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastTimelineTrackerTest.java @@ -15,21 +15,30 @@ */ package androidx.media3.cast; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import androidx.media3.common.C; +import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.common.Timeline.Window; import androidx.media3.common.util.Util; import androidx.media3.test.utils.TimelineAsserts; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; import com.google.android.gms.cast.MediaStatus; import com.google.android.gms.cast.framework.media.MediaQueue; import com.google.android.gms.cast.framework.media.RemoteMediaClient; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.List; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mockito; /** Tests for {@link CastTimelineTracker}. */ @RunWith(AndroidJUnit4.class) @@ -40,10 +49,19 @@ public class CastTimelineTrackerTest { private static final long DURATION_4_MS = 4000; private static final long DURATION_5_MS = 5000; + private MediaItemConverter mediaItemConverter; + private CastTimelineTracker castTimelineTracker; + + @Before + public void init() { + mediaItemConverter = new DefaultMediaItemConverter(); + castTimelineTracker = new CastTimelineTracker(mediaItemConverter); + } + /** Tests that duration of the current media info is correctly propagated to the timeline. */ @Test public void getCastTimelinePersistsDuration() { - CastTimelineTracker tracker = new CastTimelineTracker(); + CastTimelineTracker tracker = new CastTimelineTracker(new DefaultMediaItemConverter()); RemoteMediaClient remoteMediaClient = mockRemoteMediaClient( @@ -104,10 +122,179 @@ public void getCastTimelinePersistsDuration() { Util.msToUs(DURATION_5_MS)); } + @Test + public void getCastTimeline_onMediaItemsSet_correctMediaItemsInTimeline() { + RemoteMediaClient mockRemoteMediaClient = mock(RemoteMediaClient.class); + MediaQueue mockMediaQueue = mock(MediaQueue.class); + MediaStatus mockMediaStatus = mock(MediaStatus.class); + ImmutableList playlistMediaItems = + ImmutableList.of(createMediaItem(0), createMediaItem(1)); + MediaQueueItem[] playlistMediaQueueItems = + new MediaQueueItem[] { + createMediaQueueItem(playlistMediaItems.get(0), 0), + createMediaQueueItem(playlistMediaItems.get(1), 1) + }; + castTimelineTracker.onMediaItemsSet(playlistMediaItems, playlistMediaQueueItems); + // Mock remote media client state after adding two items. + when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue); + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {0, 1}); + when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus); + when(mockMediaStatus.getCurrentItemId()).thenReturn(0); + when(mockMediaStatus.getMediaInfo()).thenReturn(playlistMediaQueueItems[0].getMedia()); + when(mockMediaStatus.getQueueItems()).thenReturn(Arrays.asList(playlistMediaQueueItems)); + + CastTimeline castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(2); + assertThat(castTimeline.getWindow(/* windowIndex= */ 0, new Window()).mediaItem) + .isEqualTo(playlistMediaItems.get(0)); + assertThat(castTimeline.getWindow(/* windowIndex= */ 1, new Window()).mediaItem) + .isEqualTo(playlistMediaItems.get(1)); + + MediaItem thirdMediaItem = createMediaItem(2); + MediaQueueItem thirdMediaQueueItem = createMediaQueueItem(thirdMediaItem, 2); + castTimelineTracker.onMediaItemsSet( + ImmutableList.of(thirdMediaItem), new MediaQueueItem[] {thirdMediaQueueItem}); + // Mock remote media client state after a single item overrides the previous playlist. + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {2}); + when(mockMediaStatus.getCurrentItemId()).thenReturn(2); + when(mockMediaStatus.getMediaInfo()).thenReturn(thirdMediaQueueItem.getMedia()); + when(mockMediaStatus.getQueueItems()).thenReturn(ImmutableList.of(thirdMediaQueueItem)); + + castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(1); + assertThat(castTimeline.getWindow(/* windowIndex= */ 0, new Window()).mediaItem) + .isEqualTo(thirdMediaItem); + } + + @Test + public void getCastTimeline_onMediaItemsAdded_correctMediaItemsInTimeline() { + RemoteMediaClient mockRemoteMediaClient = mock(RemoteMediaClient.class); + MediaQueue mockMediaQueue = mock(MediaQueue.class); + MediaStatus mockMediaStatus = mock(MediaStatus.class); + ImmutableList playlistMediaItems = + ImmutableList.of(createMediaItem(0), createMediaItem(1)); + MediaQueueItem[] playlistQueueItems = + new MediaQueueItem[] { + createMediaQueueItem(playlistMediaItems.get(0), /* uid= */ 0), + createMediaQueueItem(playlistMediaItems.get(1), /* uid= */ 1) + }; + ImmutableList secondPlaylistMediaItems = + new ImmutableList.Builder() + .addAll(playlistMediaItems) + .add(createMediaItem(2)) + .build(); + castTimelineTracker.onMediaItemsAdded(playlistMediaItems, playlistQueueItems); + when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue); + when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus); + // Mock remote media client state after two items have been added. + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {0, 1}); + when(mockMediaStatus.getCurrentItemId()).thenReturn(0); + when(mockMediaStatus.getMediaInfo()).thenReturn(playlistQueueItems[0].getMedia()); + when(mockMediaStatus.getQueueItems()).thenReturn(Arrays.asList(playlistQueueItems)); + + CastTimeline castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(2); + assertThat(castTimeline.getWindow(/* windowIndex= */ 0, new Window()).mediaItem) + .isEqualTo(playlistMediaItems.get(0)); + assertThat(castTimeline.getWindow(/* windowIndex= */ 1, new Window()).mediaItem) + .isEqualTo(playlistMediaItems.get(1)); + + // Mock remote media client state after adding a third item. + List playlistThreeQueueItems = + new ArrayList<>(Arrays.asList(playlistQueueItems)); + playlistThreeQueueItems.add(createMediaQueueItem(secondPlaylistMediaItems.get(2), 2)); + castTimelineTracker.onMediaItemsAdded( + secondPlaylistMediaItems, playlistThreeQueueItems.toArray(new MediaQueueItem[0])); + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {0, 1, 2}); + when(mockMediaStatus.getQueueItems()).thenReturn(playlistThreeQueueItems); + + castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(3); + assertThat(castTimeline.getWindow(/* windowIndex= */ 0, new Window()).mediaItem) + .isEqualTo(secondPlaylistMediaItems.get(0)); + assertThat(castTimeline.getWindow(/* windowIndex= */ 1, new Window()).mediaItem) + .isEqualTo(secondPlaylistMediaItems.get(1)); + assertThat(castTimeline.getWindow(/* windowIndex= */ 2, new Window()).mediaItem) + .isEqualTo(secondPlaylistMediaItems.get(2)); + } + + @Test + public void getCastTimeline_itemsRemoved_correctMediaItemsInTimelineAndMapCleanedUp() { + RemoteMediaClient mockRemoteMediaClient = mock(RemoteMediaClient.class); + MediaQueue mockMediaQueue = mock(MediaQueue.class); + MediaStatus mockMediaStatus = mock(MediaStatus.class); + ImmutableList playlistMediaItems = + ImmutableList.of(createMediaItem(0), createMediaItem(1)); + MediaQueueItem[] initialPlaylistTwoQueueItems = + new MediaQueueItem[] { + createMediaQueueItem(playlistMediaItems.get(0), 0), + createMediaQueueItem(playlistMediaItems.get(1), 1) + }; + castTimelineTracker.onMediaItemsSet(playlistMediaItems, initialPlaylistTwoQueueItems); + when(mockRemoteMediaClient.getMediaQueue()).thenReturn(mockMediaQueue); + when(mockRemoteMediaClient.getMediaStatus()).thenReturn(mockMediaStatus); + // Mock remote media client state with two items in the queue. + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {0, 1}); + when(mockMediaStatus.getCurrentItemId()).thenReturn(0); + when(mockMediaStatus.getMediaInfo()).thenReturn(initialPlaylistTwoQueueItems[0].getMedia()); + when(mockMediaStatus.getQueueItems()).thenReturn(Arrays.asList(initialPlaylistTwoQueueItems)); + + CastTimeline castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(2); + assertThat(castTimelineTracker.mediaItemsByContentId).hasSize(2); + + // Mock remote media client state after the first item has been removed. + when(mockMediaQueue.getItemIds()).thenReturn(new int[] {1}); + when(mockMediaStatus.getCurrentItemId()).thenReturn(1); + when(mockMediaStatus.getMediaInfo()).thenReturn(initialPlaylistTwoQueueItems[1].getMedia()); + when(mockMediaStatus.getQueueItems()) + .thenReturn(ImmutableList.of(initialPlaylistTwoQueueItems[1])); + + castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(1); + assertThat(castTimeline.getWindow(/* windowIndex= */ 0, new Window()).mediaItem) + .isEqualTo(playlistMediaItems.get(1)); + // Assert that the removed item has been removed from the content ID map. + assertThat(castTimelineTracker.mediaItemsByContentId).hasSize(1); + + // Mock remote media client state for empty queue. + when(mockRemoteMediaClient.getMediaStatus()).thenReturn(null); + when(mockMediaQueue.getItemIds()).thenReturn(new int[0]); + when(mockMediaStatus.getCurrentItemId()).thenReturn(MediaQueueItem.INVALID_ITEM_ID); + when(mockMediaStatus.getMediaInfo()).thenReturn(null); + when(mockMediaStatus.getQueueItems()).thenReturn(ImmutableList.of()); + + castTimeline = castTimelineTracker.getCastTimeline(mockRemoteMediaClient); + + assertThat(castTimeline.getWindowCount()).isEqualTo(0); + // Queue is not emptied when remote media client is empty. See [Internal ref: b/128825216]. + assertThat(castTimelineTracker.mediaItemsByContentId).hasSize(1); + } + + private MediaItem createMediaItem(int uid) { + return new MediaItem.Builder() + .setUri("http://www.google.com/" + uid) + .setMimeType(MimeTypes.AUDIO_MPEG) + .setTag(uid) + .build(); + } + + private MediaQueueItem createMediaQueueItem(MediaItem mediaItem, int uid) { + return new MediaQueueItem.Builder(mediaItemConverter.toMediaQueueItem(mediaItem)) + .setItemId(uid) + .build(); + } + private static RemoteMediaClient mockRemoteMediaClient( int[] itemIds, int currentItemId, long currentDurationMs) { - RemoteMediaClient remoteMediaClient = Mockito.mock(RemoteMediaClient.class); - MediaStatus status = Mockito.mock(MediaStatus.class); + RemoteMediaClient remoteMediaClient = mock(RemoteMediaClient.class); + MediaStatus status = mock(MediaStatus.class); when(status.getQueueItems()).thenReturn(Collections.emptyList()); when(remoteMediaClient.getMediaStatus()).thenReturn(status); when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs)); @@ -118,7 +305,7 @@ private static RemoteMediaClient mockRemoteMediaClient( } private static MediaQueue mockMediaQueue(int[] itemIds) { - MediaQueue mediaQueue = Mockito.mock(MediaQueue.class); + MediaQueue mediaQueue = mock(MediaQueue.class); when(mediaQueue.getItemIds()).thenReturn(itemIds); return mediaQueue; } diff --git a/libraries/cast/src/test/java/androidx/media3/cast/DefaultMediaItemConverterTest.java b/libraries/cast/src/test/java/androidx/media3/cast/DefaultMediaItemConverterTest.java index 0a760043d37..10ac47a62ea 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/DefaultMediaItemConverterTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/DefaultMediaItemConverterTest.java @@ -50,6 +50,7 @@ public void serialize_deserialize_complete() { MediaItem.Builder builder = new MediaItem.Builder(); MediaItem item = builder + .setMediaId("fooBar") .setUri(Uri.parse("http://example.com")) .setMediaMetadata(MediaMetadata.EMPTY) .setMimeType(MimeTypes.APPLICATION_MPD) @@ -66,4 +67,45 @@ public void serialize_deserialize_complete() { assertThat(reconstructedItem).isEqualTo(item); } + + @Test + public void toMediaQueueItem_nonDefaultMediaId_usedAsContentId() { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem item = + builder + .setMediaId("fooBar") + .setUri("http://example.com") + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(); + + DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); + MediaQueueItem queueItem = converter.toMediaQueueItem(item); + + assertThat(queueItem.getMedia().getContentId()).isEqualTo("fooBar"); + } + + @Test + public void toMediaQueueItem_defaultMediaId_uriAsContentId() { + DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("http://example.com") + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(); + + MediaQueueItem queueItem = converter.toMediaQueueItem(mediaItem); + + assertThat(queueItem.getMedia().getContentId()).isEqualTo("http://example.com"); + + MediaItem secondMediaItem = + new MediaItem.Builder() + .setMediaId(MediaItem.DEFAULT_MEDIA_ID) + .setUri("http://example.com") + .setMimeType(MimeTypes.APPLICATION_MPD) + .build(); + + MediaQueueItem secondQueueItem = converter.toMediaQueueItem(secondMediaItem); + + assertThat(secondQueueItem.getMedia().getContentId()).isEqualTo("http://example.com"); + } } diff --git a/libraries/common/build.gradle b/libraries/common/build.gradle index 048fe60f41b..85169e2ec8b 100644 --- a/libraries/common/build.gradle +++ b/libraries/common/build.gradle @@ -75,6 +75,7 @@ dependencies { testImplementation 'junit:junit:' + junitVersion testImplementation 'com.google.truth:truth:' + truthVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion + testImplementation project(modulePrefix + 'lib-exoplayer') testImplementation project(modulePrefix + 'test-utils') } diff --git a/libraries/common/src/main/java/androidx/media3/common/DrmInitData.java b/libraries/common/src/main/java/androidx/media3/common/DrmInitData.java index 7a971ef9802..65fe0958289 100644 --- a/libraries/common/src/main/java/androidx/media3/common/DrmInitData.java +++ b/libraries/common/src/main/java/androidx/media3/common/DrmInitData.java @@ -18,6 +18,7 @@ import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; +import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import androidx.media3.common.DrmInitData.SchemeData; import androidx.media3.common.util.Assertions; @@ -157,6 +158,7 @@ public SchemeData get(int index) { * @param schemeType A protection scheme type. May be null. * @return A copy with the specified protection scheme type. */ + @CheckResult public DrmInitData copyWithSchemeType(@Nullable String schemeType) { if (Util.areEqual(this.schemeType, schemeType)) { return this; @@ -333,6 +335,7 @@ public boolean hasData() { * @param data The data to include in the copy. * @return The new instance. */ + @CheckResult public SchemeData copyWithData(@Nullable byte[] data) { return new SchemeData(uuid, licenseServerUrl, mimeType, data); } diff --git a/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java b/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java index 4e87f65806c..62be209a9b8 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java +++ b/libraries/common/src/main/java/androidx/media3/common/MediaLibraryInfo.java @@ -29,11 +29,11 @@ public final class MediaLibraryInfo { /** The version of the library expressed as a string, for example "1.2.3" or "1.2.3-beta01". */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. - public static final String VERSION = "1.0.0-beta01"; + public static final String VERSION = "1.0.0-beta02"; /** The version of the library expressed as {@code TAG + "/" + VERSION}. */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-beta01"; + public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0-beta02"; /** * The version of the library expressed as an integer, for example 1002003300. @@ -47,7 +47,7 @@ public final class MediaLibraryInfo { * (123-045-006-3-00). */ // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. - public static final int VERSION_INT = 1_000_000_1_01; + public static final int VERSION_INT = 1_000_000_1_02; /** Whether the library was compiled with {@link Assertions} checks enabled. */ public static final boolean ASSERTIONS_ENABLED = true; diff --git a/libraries/common/src/main/java/androidx/media3/common/Player.java b/libraries/common/src/main/java/androidx/media3/common/Player.java index 8cd90d2da18..4f2834b1ef2 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -373,6 +373,7 @@ public static final class Builder { COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_GET_AUDIO_ATTRIBUTES, COMMAND_GET_VOLUME, @@ -384,7 +385,6 @@ public static final class Builder { COMMAND_GET_TEXT, COMMAND_SET_TRACK_SELECTION_PARAMETERS, COMMAND_GET_TRACKS, - COMMAND_SET_MEDIA_ITEM, }; private final FlagSet.Builder flagsBuilder; @@ -1432,6 +1432,7 @@ default void onMetadata(Metadata metadata) {} COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_GET_AUDIO_ATTRIBUTES, COMMAND_GET_VOLUME, @@ -1443,7 +1444,6 @@ default void onMetadata(Metadata metadata) {} COMMAND_GET_TEXT, COMMAND_SET_TRACK_SELECTION_PARAMETERS, COMMAND_GET_TRACKS, - COMMAND_SET_MEDIA_ITEM, }) @interface Command {} /** Command to start, pause or resume playback. */ @@ -1501,6 +1501,8 @@ default void onMetadata(Metadata metadata) {} int COMMAND_GET_MEDIA_ITEMS_METADATA = 18; /** Command to set the {@link MediaItem MediaItems} metadata. */ int COMMAND_SET_MEDIA_ITEMS_METADATA = 19; + /** Command to set a {@link MediaItem MediaItem}. */ + int COMMAND_SET_MEDIA_ITEM = 31; /** Command to change the {@link MediaItem MediaItems} in the playlist. */ int COMMAND_CHANGE_MEDIA_ITEMS = 20; /** Command to get the player current {@link AudioAttributes}. */ @@ -1523,8 +1525,6 @@ default void onMetadata(Metadata metadata) {} int COMMAND_SET_TRACK_SELECTION_PARAMETERS = 29; /** Command to get details of the current track selection. */ int COMMAND_GET_TRACKS = 30; - /** Command to set a {@link MediaItem MediaItem}. */ - int COMMAND_SET_MEDIA_ITEM = 31; /** Represents an invalid {@link Command}. */ int COMMAND_INVALID = -1; diff --git a/libraries/common/src/main/java/androidx/media3/common/Timeline.java b/libraries/common/src/main/java/androidx/media3/common/Timeline.java index 3f665f8bfc8..14b04065f50 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Timeline.java +++ b/libraries/common/src/main/java/androidx/media3/common/Timeline.java @@ -1351,6 +1351,27 @@ public boolean equals(@Nullable Object obj) { return false; } } + + // Check shuffled order + int windowIndex = getFirstWindowIndex(/* shuffleModeEnabled= */ true); + if (windowIndex != other.getFirstWindowIndex(/* shuffleModeEnabled= */ true)) { + return false; + } + int lastWindowIndex = getLastWindowIndex(/* shuffleModeEnabled= */ true); + if (lastWindowIndex != other.getLastWindowIndex(/* shuffleModeEnabled= */ true)) { + return false; + } + while (windowIndex != lastWindowIndex) { + int nextWindowIndex = + getNextWindowIndex(windowIndex, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true); + if (nextWindowIndex + != other.getNextWindowIndex( + windowIndex, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) { + return false; + } + windowIndex = nextWindowIndex; + } + return true; } @@ -1367,6 +1388,13 @@ public int hashCode() { for (int i = 0; i < getPeriodCount(); i++) { result = 31 * result + getPeriod(i, period, /* setIds= */ true).hashCode(); } + + for (int windowIndex = getFirstWindowIndex(true); + windowIndex != C.INDEX_UNSET; + windowIndex = getNextWindowIndex(windowIndex, Player.REPEAT_MODE_OFF, true)) { + result = 31 * result + windowIndex; + } + return result; } diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Log.java b/libraries/common/src/main/java/androidx/media3/common/util/Log.java index ce0d25dfadc..b1a97f77fa0 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Log.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Log.java @@ -18,6 +18,7 @@ import static java.lang.annotation.ElementType.TYPE_USE; import android.text.TextUtils; +import androidx.annotation.GuardedBy; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.Size; @@ -28,7 +29,10 @@ import java.net.UnknownHostException; import org.checkerframework.dataflow.qual.Pure; -/** Wrapper around {@link android.util.Log} which allows to set the log level. */ +/** + * Wrapper around {@link android.util.Log} which allows to set the log level and to specify a custom + * log output. + */ @UnstableApi public final class Log { @@ -52,15 +56,89 @@ public final class Log { /** Log level to disable all logging. */ public static final int LOG_LEVEL_OFF = Integer.MAX_VALUE; + /** + * Interface for a logger that can output messages with a tag. + * + *

Use {@link #DEFAULT} to output to {@link android.util.Log}. + */ + public interface Logger { + + /** The default instance logging to {@link android.util.Log}. */ + Logger DEFAULT = + new Logger() { + @Override + public void d(String tag, String message) { + android.util.Log.d(tag, message); + } + + @Override + public void i(String tag, String message) { + android.util.Log.i(tag, message); + } + + @Override + public void w(String tag, String message) { + android.util.Log.w(tag, message); + } + + @Override + public void e(String tag, String message) { + android.util.Log.e(tag, message); + } + }; + + /** + * Logs a debug-level message. + * + * @param tag The tag of the message. + * @param message The message. + */ + void d(String tag, String message); + + /** + * Logs an information-level message. + * + * @param tag The tag of the message. + * @param message The message. + */ + void i(String tag, String message); + + /** + * Logs a warning-level message. + * + * @param tag The tag of the message. + * @param message The message. + */ + void w(String tag, String message); + + /** + * Logs an error-level message. + * + * @param tag The tag of the message. + * @param message The message. + */ + void e(String tag, String message); + } + + private static final Object lock = new Object(); + + @GuardedBy("lock") private static int logLevel = LOG_LEVEL_ALL; + + @GuardedBy("lock") private static boolean logStackTraces = true; + @GuardedBy("lock") + private static Logger logger = Logger.DEFAULT; + private Log() {} /** Returns current {@link LogLevel} for ExoPlayer logcat logging. */ @Pure public static @LogLevel int getLogLevel() { - return logLevel; + synchronized (lock) { + return logLevel; + } } /** @@ -69,7 +147,9 @@ private Log() {} * @param logLevel The new {@link LogLevel}. */ public static void setLogLevel(@LogLevel int logLevel) { - Log.logLevel = logLevel; + synchronized (lock) { + Log.logLevel = logLevel; + } } /** @@ -79,7 +159,20 @@ public static void setLogLevel(@LogLevel int logLevel) { * @param logStackTraces Whether stack traces will be logged. */ public static void setLogStackTraces(boolean logStackTraces) { - Log.logStackTraces = logStackTraces; + synchronized (lock) { + Log.logStackTraces = logStackTraces; + } + } + + /** + * Sets a custom {@link Logger} as the output. + * + * @param logger The {@link Logger}. + */ + public static void setLogger(Logger logger) { + synchronized (lock) { + Log.logger = logger; + } } /** @@ -87,8 +180,10 @@ public static void setLogStackTraces(boolean logStackTraces) { */ @Pure public static void d(@Size(max = 23) String tag, String message) { - if (logLevel == LOG_LEVEL_ALL) { - android.util.Log.d(tag, message); + synchronized (lock) { + if (logLevel == LOG_LEVEL_ALL) { + logger.d(tag, message); + } } } @@ -105,8 +200,10 @@ public static void d(@Size(max = 23) String tag, String message, @Nullable Throw */ @Pure public static void i(@Size(max = 23) String tag, String message) { - if (logLevel <= LOG_LEVEL_INFO) { - android.util.Log.i(tag, message); + synchronized (lock) { + if (logLevel <= LOG_LEVEL_INFO) { + logger.i(tag, message); + } } } @@ -123,8 +220,10 @@ public static void i(@Size(max = 23) String tag, String message, @Nullable Throw */ @Pure public static void w(@Size(max = 23) String tag, String message) { - if (logLevel <= LOG_LEVEL_WARNING) { - android.util.Log.w(tag, message); + synchronized (lock) { + if (logLevel <= LOG_LEVEL_WARNING) { + logger.w(tag, message); + } } } @@ -141,8 +240,10 @@ public static void w(@Size(max = 23) String tag, String message, @Nullable Throw */ @Pure public static void e(@Size(max = 23) String tag, String message) { - if (logLevel <= LOG_LEVEL_ERROR) { - android.util.Log.e(tag, message); + synchronized (lock) { + if (logLevel <= LOG_LEVEL_ERROR) { + logger.e(tag, message); + } } } @@ -168,20 +269,23 @@ public static void e(@Size(max = 23) String tag, String message, @Nullable Throw @Nullable @Pure public static String getThrowableString(@Nullable Throwable throwable) { - if (throwable == null) { - return null; - } else if (isCausedByUnknownHostException(throwable)) { - // UnknownHostException implies the device doesn't have network connectivity. - // UnknownHostException.getMessage() may return a string that's more verbose than desired for - // logging an expected failure mode. Conversely, android.util.Log.getStackTraceString has - // special handling to return the empty string, which can result in logging that doesn't - // indicate the failure mode at all. Hence we special case this exception to always return a - // concise but useful message. - return "UnknownHostException (no network)"; - } else if (!logStackTraces) { - return throwable.getMessage(); - } else { - return android.util.Log.getStackTraceString(throwable).trim().replace("\t", " "); + synchronized (lock) { + if (throwable == null) { + return null; + } else if (isCausedByUnknownHostException(throwable)) { + // UnknownHostException implies the device doesn't have network connectivity. + // UnknownHostException.getMessage() may return a string that's more verbose than desired + // for + // logging an expected failure mode. Conversely, android.util.Log.getStackTraceString has + // special handling to return the empty string, which can result in logging that doesn't + // indicate the failure mode at all. Hence we special case this exception to always return a + // concise but useful message. + return "UnknownHostException (no network)"; + } else if (!logStackTraces) { + return throwable.getMessage(); + } else { + return android.util.Log.getStackTraceString(throwable).trim().replace("\t", " "); + } } } diff --git a/libraries/common/src/main/java/androidx/media3/common/util/Util.java b/libraries/common/src/main/java/androidx/media3/common/util/Util.java index 7238fad0fe8..7ea6c3d1f86 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/Util.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/Util.java @@ -55,6 +55,7 @@ import android.os.Looper; import android.os.Parcel; import android.os.SystemClock; +import android.provider.MediaStore; import android.security.NetworkSecurityPolicy; import android.telephony.TelephonyManager; import android.text.TextUtils; @@ -199,7 +200,7 @@ public static byte[] toByteArray(InputStream inputStream) throws IOException { @UnstableApi @Nullable public static ComponentName startForegroundService(Context context, Intent intent) { - if (Util.SDK_INT >= 26) { + if (SDK_INT >= 26) { return context.startForegroundService(intent); } else { return context.startService(intent); @@ -215,12 +216,12 @@ public static ComponentName startForegroundService(Context context, Intent inten * @return Whether a permission request was made. */ public static boolean maybeRequestReadExternalStoragePermission(Activity activity, Uri... uris) { - if (Util.SDK_INT < 23) { + if (SDK_INT < 23) { return false; } for (Uri uri : uris) { - if (isLocalFileUri(uri)) { - return requestExternalStoragePermission(activity); + if (maybeRequestReadExternalStoragePermission(activity, uri)) { + return true; } } return false; @@ -238,25 +239,46 @@ public static boolean maybeRequestReadExternalStoragePermission(Activity activit */ public static boolean maybeRequestReadExternalStoragePermission( Activity activity, MediaItem... mediaItems) { - if (Util.SDK_INT < 23) { + if (SDK_INT < 23) { return false; } for (MediaItem mediaItem : mediaItems) { if (mediaItem.localConfiguration == null) { continue; } - if (isLocalFileUri(mediaItem.localConfiguration.uri)) { - return requestExternalStoragePermission(activity); + if (maybeRequestReadExternalStoragePermission(activity, mediaItem.localConfiguration.uri)) { + return true; } - for (int i = 0; i < mediaItem.localConfiguration.subtitleConfigurations.size(); i++) { - if (isLocalFileUri(mediaItem.localConfiguration.subtitleConfigurations.get(i).uri)) { - return requestExternalStoragePermission(activity); + List subtitleConfigs = + mediaItem.localConfiguration.subtitleConfigurations; + for (int i = 0; i < subtitleConfigs.size(); i++) { + if (maybeRequestReadExternalStoragePermission(activity, subtitleConfigs.get(i).uri)) { + return true; } } } return false; } + private static boolean maybeRequestReadExternalStoragePermission(Activity activity, Uri uri) { + return SDK_INT >= 23 + && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) + && requestExternalStoragePermission(activity); + } + + private static boolean isMediaStoreExternalContentUri(Uri uri) { + if (!"content".equals(uri.getScheme()) || !MediaStore.AUTHORITY.equals(uri.getAuthority())) { + return false; + } + List pathSegments = uri.getPathSegments(); + if (pathSegments.isEmpty()) { + return false; + } + String firstPathSegment = pathSegments.get(0); + return MediaStore.VOLUME_EXTERNAL.equals(firstPathSegment) + || MediaStore.VOLUME_EXTERNAL_PRIMARY.equals(firstPathSegment); + } + /** * Returns whether it may be possible to load the URIs of the given media items based on the * network security policy's cleartext traffic permissions. @@ -265,7 +287,7 @@ public static boolean maybeRequestReadExternalStoragePermission( * @return Whether it may be possible to load the URIs of the given media items. */ public static boolean checkCleartextTrafficPermitted(MediaItem... mediaItems) { - if (Util.SDK_INT < 24) { + if (SDK_INT < 24) { // We assume cleartext traffic is permitted. return true; } @@ -650,7 +672,7 @@ public static String getLocaleLanguageTag(Locale locale) { normalizedTag = language; } normalizedTag = Ascii.toLowerCase(normalizedTag); - String mainLanguage = Util.splitAtFirst(normalizedTag, "-")[0]; + String mainLanguage = splitAtFirst(normalizedTag, "-")[0]; if (languageTagReplacementMap == null) { languageTagReplacementMap = createIsoLanguageReplacementMap(); } @@ -1712,9 +1734,9 @@ public static int getAudioTrackChannelConfig(int channelCount) { case 7: return AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER; case 8: - if (Util.SDK_INT >= 23) { + if (SDK_INT >= 23) { return AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; - } else if (Util.SDK_INT >= 21) { + } else if (SDK_INT >= 21) { // Equal to AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, which is hidden before Android M. return AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_SIDE_LEFT @@ -2005,7 +2027,7 @@ public static UUID getDrmUuid(String drmScheme) { public static @ContentType int inferContentTypeForUriAndMimeType( Uri uri, @Nullable String mimeType) { if (mimeType == null) { - return Util.inferContentType(uri); + return inferContentType(uri); } switch (mimeType) { case MimeTypes.APPLICATION_MPD: @@ -2345,7 +2367,7 @@ public static String[] getSystemLanguageCodes() { /** Returns the default {@link Locale.Category#DISPLAY DISPLAY} {@link Locale}. */ @UnstableApi public static Locale getDefaultDisplayLocale() { - return Util.SDK_INT >= 24 ? Locale.getDefault(Locale.Category.DISPLAY) : Locale.getDefault(); + return SDK_INT >= 24 ? Locale.getDefault(Locale.Category.DISPLAY) : Locale.getDefault(); } /** @@ -2420,7 +2442,7 @@ public static boolean isTv(Context context) { */ @UnstableApi public static boolean isAutomotive(Context context) { - return Util.SDK_INT >= 23 + return SDK_INT >= 23 && context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE); } @@ -2439,7 +2461,7 @@ public static boolean isAutomotive(Context context) { @UnstableApi public static Point getCurrentDisplayModeSize(Context context) { @Nullable Display defaultDisplay = null; - if (Util.SDK_INT >= 17) { + if (SDK_INT >= 17) { @Nullable DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); @@ -2488,7 +2510,7 @@ public static Point getCurrentDisplayModeSize(Context context, Display display) // vendor.display-size instead. @Nullable String displaySize = - Util.SDK_INT < 28 + SDK_INT < 28 ? getSystemProperty("sys.display-size") : getSystemProperty("vendor.display-size"); // If we managed to read the display size, attempt to parse it. @@ -2509,17 +2531,17 @@ public static Point getCurrentDisplayModeSize(Context context, Display display) } // Sony Android TVs advertise support for 4k output via a system feature. - if ("Sony".equals(Util.MANUFACTURER) - && Util.MODEL.startsWith("BRAVIA") + if ("Sony".equals(MANUFACTURER) + && MODEL.startsWith("BRAVIA") && context.getPackageManager().hasSystemFeature("com.sony.dtv.hardware.panel.qfhd")) { return new Point(3840, 2160); } } Point displaySize = new Point(); - if (Util.SDK_INT >= 23) { + if (SDK_INT >= 23) { getDisplaySizeV23(display, displaySize); - } else if (Util.SDK_INT >= 17) { + } else if (SDK_INT >= 17) { getDisplaySizeV17(display, displaySize); } else { getDisplaySizeV16(display, displaySize); @@ -2745,7 +2767,7 @@ private static String[] getSystemLocales() { @RequiresApi(24) private static String[] getSystemLocalesV24(Configuration config) { - return Util.split(config.getLocales().toLanguageTags(), ","); + return split(config.getLocales().toLanguageTags(), ","); } @RequiresApi(21) diff --git a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java index f2de7511930..6844330e14f 100644 --- a/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/TimelineTest.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import androidx.media3.common.MediaItem.LiveConfiguration; +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.TimelineAsserts; @@ -64,6 +65,50 @@ public void multiPeriodTimeline() { TimelineAsserts.assertNextWindowIndices(timeline, Player.REPEAT_MODE_ALL, false, 0); } + @Test + public void timelineEquals() { + ImmutableList timelineWindowDefinitions = + ImmutableList.of( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 111), + new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 222), + new TimelineWindowDefinition(/* periodCount= */ 3, /* id= */ 333)); + Timeline timeline1 = + new FakeTimeline(timelineWindowDefinitions.toArray(new TimelineWindowDefinition[0])); + Timeline timeline2 = + new FakeTimeline(timelineWindowDefinitions.toArray(new TimelineWindowDefinition[0])); + + assertThat(timeline1).isEqualTo(timeline2); + assertThat(timeline1.hashCode()).isEqualTo(timeline2.hashCode()); + } + + @Test + public void timelineEquals_includesShuffleOrder() { + ImmutableList timelineWindowDefinitions = + ImmutableList.of( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 111), + new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 222), + new TimelineWindowDefinition(/* periodCount= */ 3, /* id= */ 333)); + Timeline timeline = + new FakeTimeline( + new Object[0], + new DefaultShuffleOrder(timelineWindowDefinitions.size(), /* randomSeed= */ 5), + timelineWindowDefinitions.toArray(new TimelineWindowDefinition[0])); + Timeline timelineWithEquivalentShuffleOrder = + new FakeTimeline( + new Object[0], + new DefaultShuffleOrder(timelineWindowDefinitions.size(), /* randomSeed= */ 5), + timelineWindowDefinitions.toArray(new TimelineWindowDefinition[0])); + Timeline timelineWithDifferentShuffleOrder = + new FakeTimeline( + new Object[0], + new DefaultShuffleOrder(timelineWindowDefinitions.size(), /* randomSeed= */ 3), + timelineWindowDefinitions.toArray(new TimelineWindowDefinition[0])); + + assertThat(timeline).isEqualTo(timelineWithEquivalentShuffleOrder); + assertThat(timeline.hashCode()).isEqualTo(timelineWithEquivalentShuffleOrder.hashCode()); + assertThat(timeline).isNotEqualTo(timelineWithDifferentShuffleOrder); + } + @Test public void windowEquals() { MediaItem mediaItem = new MediaItem.Builder().setUri("uri").setTag(new Object()).build(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 389112484a0..76731b4e1c4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -294,6 +294,7 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer) COMMAND_GET_TIMELINE, COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_GET_TRACKS, COMMAND_GET_AUDIO_ATTRIBUTES, @@ -303,8 +304,7 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer) COMMAND_SET_DEVICE_VOLUME, COMMAND_ADJUST_DEVICE_VOLUME, COMMAND_SET_VIDEO_SURFACE, - COMMAND_GET_TEXT, - COMMAND_SET_MEDIA_ITEM) + COMMAND_GET_TEXT) .addIf( COMMAND_SET_TRACK_SELECTION_PARAMETERS, trackSelector.isSetParametersSupported()) .build(); @@ -433,6 +433,9 @@ public DeviceComponent getDeviceComponent() { public void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { verifyApplicationThread(); internalPlayer.experimentalSetOffloadSchedulingEnabled(offloadSchedulingEnabled); + for (AudioOffloadListener listener : audioOffloadListeners) { + listener.onExperimentalOffloadSchedulingEnabledChanged(offloadSchedulingEnabled); + } } @Override @@ -707,6 +710,7 @@ public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { @Override public void setShuffleOrder(ShuffleOrder shuffleOrder) { verifyApplicationThread(); + this.shuffleOrder = shuffleOrder; Timeline timeline = createMaskingTimeline(); PlaybackInfo newPlaybackInfo = maskTimelineAndPosition( @@ -715,7 +719,6 @@ public void setShuffleOrder(ShuffleOrder shuffleOrder) { maskWindowPositionMsOrGetPeriodPositionUs( timeline, getCurrentMediaItemIndex(), getCurrentPosition())); pendingOperationAcks++; - this.shuffleOrder = shuffleOrder; internalPlayer.setShuffleOrder(shuffleOrder); updatePlaybackInfo( newPlaybackInfo, @@ -1962,12 +1965,6 @@ private void updatePlaybackInfo( updateAvailableCommands(); listeners.flushEvents(); - if (previousPlaybackInfo.offloadSchedulingEnabled != newPlaybackInfo.offloadSchedulingEnabled) { - for (AudioOffloadListener listener : audioOffloadListeners) { - listener.onExperimentalOffloadSchedulingEnabledChanged( - newPlaybackInfo.offloadSchedulingEnabled); - } - } if (previousPlaybackInfo.sleepingForOffload != newPlaybackInfo.sleepingForOffload) { for (AudioOffloadListener listener : audioOffloadListeners) { listener.onExperimentalSleepingForOffloadChanged(newPlaybackInfo.sleepingForOffload); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index 6eba41d01ef..a9f0af61117 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -817,10 +817,8 @@ private void setOffloadSchedulingEnabledInternal(boolean offloadSchedulingEnable return; } this.offloadSchedulingEnabled = offloadSchedulingEnabled; - @Player.State int state = playbackInfo.playbackState; - if (offloadSchedulingEnabled || state == Player.STATE_ENDED || state == Player.STATE_IDLE) { - playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); - } else { + if (!offloadSchedulingEnabled && playbackInfo.sleepingForOffload) { + // We need to wake the player up if offload scheduling is disabled and we are sleeping. handler.sendEmptyMessage(MSG_DO_SOME_WORK); } } @@ -960,12 +958,14 @@ private void notifyTrackSelectionRebuffer() { private void doSomeWork() throws ExoPlaybackException, IOException { long operationStartTimeMs = clock.uptimeMillis(); + // Remove other pending DO_SOME_WORK requests that are handled by this invocation. + handler.removeMessages(MSG_DO_SOME_WORK); + updatePeriods(); if (playbackInfo.playbackState == Player.STATE_IDLE || playbackInfo.playbackState == Player.STATE_ENDED) { - // Remove all messages. Prepare (in case of IDLE) or seek (in case of ENDED) will resume. - handler.removeMessages(MSG_DO_SOME_WORK); + // Nothing to do. Prepare (in case of IDLE) or seek (in case of ENDED) will resume. return; } @@ -1078,24 +1078,24 @@ && isLoadingPossible()) { throw new IllegalStateException("Playback stuck buffering and not loading"); } - if (offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled) { - playbackInfo = playbackInfo.copyWithOffloadSchedulingEnabled(offloadSchedulingEnabled); - } - - boolean sleepingForOffload = false; - if ((shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY) - || playbackInfo.playbackState == Player.STATE_BUFFERING) { - sleepingForOffload = !maybeScheduleWakeup(operationStartTimeMs, ACTIVE_INTERVAL_MS); - } else if (enabledRendererCount != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { - scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); - } else { - handler.removeMessages(MSG_DO_SOME_WORK); - } + boolean isPlaying = shouldPlayWhenReady() && playbackInfo.playbackState == Player.STATE_READY; + boolean sleepingForOffload = offloadSchedulingEnabled && requestForRendererSleep && isPlaying; if (playbackInfo.sleepingForOffload != sleepingForOffload) { playbackInfo = playbackInfo.copyWithSleepingForOffload(sleepingForOffload); } requestForRendererSleep = false; // A sleep request is only valid for the current doSomeWork. + if (sleepingForOffload || playbackInfo.playbackState == Player.STATE_ENDED) { + // No need to schedule next work. + return; + } else if (isPlaying || playbackInfo.playbackState == Player.STATE_BUFFERING) { + // We are actively playing or waiting for data to be ready. Schedule next work quickly. + scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + } else if (playbackInfo.playbackState == Player.STATE_READY && enabledRendererCount != 0) { + // We are ready, but not playing. Schedule next work less often to handle non-urgent updates. + scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); + } + TraceUtil.endSection(); } @@ -1125,19 +1125,9 @@ private boolean shouldUseLivePlaybackSpeedControl( } private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { - handler.removeMessages(MSG_DO_SOME_WORK); handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } - private boolean maybeScheduleWakeup(long operationStartTimeMs, long intervalMs) { - if (offloadSchedulingEnabled && requestForRendererSleep) { - return false; - } - - scheduleNextWork(operationStartTimeMs, intervalMs); - return true; - } - private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); @@ -1468,7 +1458,6 @@ private void resetInternal( /* bufferedPositionUs= */ startPositionUs, /* totalBufferedDurationUs= */ 0, /* positionUs= */ startPositionUs, - offloadSchedulingEnabled, /* sleepingForOffload= */ false); if (releaseMediaSourceList) { mediaSourceList.release(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaybackInfo.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaybackInfo.java index f0e104a75ac..9ea9b0e971f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaybackInfo.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaybackInfo.java @@ -74,8 +74,6 @@ public final @PlaybackSuppressionReason int playbackSuppressionReason; /** The playback parameters. */ public final PlaybackParameters playbackParameters; - /** Whether offload scheduling is enabled for the main player loop. */ - public final boolean offloadSchedulingEnabled; /** Whether the main player loop is sleeping, while using offload scheduling. */ public final boolean sleepingForOffload; @@ -122,7 +120,6 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, /* positionUs= */ 0, - /* offloadSchedulingEnabled= */ false, /* sleepingForOffload= */ false); } @@ -145,7 +142,6 @@ public static PlaybackInfo createDummy(TrackSelectorResult emptyTrackSelectorRes * @param bufferedPositionUs See {@link #bufferedPositionUs}. * @param totalBufferedDurationUs See {@link #totalBufferedDurationUs}. * @param positionUs See {@link #positionUs}. - * @param offloadSchedulingEnabled See {@link #offloadSchedulingEnabled}. * @param sleepingForOffload See {@link #sleepingForOffload}. */ public PlaybackInfo( @@ -166,7 +162,6 @@ public PlaybackInfo( long bufferedPositionUs, long totalBufferedDurationUs, long positionUs, - boolean offloadSchedulingEnabled, boolean sleepingForOffload) { this.timeline = timeline; this.periodId = periodId; @@ -185,7 +180,6 @@ public PlaybackInfo( this.bufferedPositionUs = bufferedPositionUs; this.totalBufferedDurationUs = totalBufferedDurationUs; this.positionUs = positionUs; - this.offloadSchedulingEnabled = offloadSchedulingEnabled; this.sleepingForOffload = sleepingForOffload; } @@ -237,7 +231,6 @@ public PlaybackInfo copyWithNewPosition( bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -267,7 +260,6 @@ public PlaybackInfo copyWithTimeline(Timeline timeline) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -297,7 +289,6 @@ public PlaybackInfo copyWithPlaybackState(int playbackState) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -327,7 +318,6 @@ public PlaybackInfo copyWithPlaybackError(@Nullable ExoPlaybackException playbac bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -357,7 +347,6 @@ public PlaybackInfo copyWithIsLoading(boolean isLoading) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -387,7 +376,6 @@ public PlaybackInfo copyWithLoadingMediaPeriodId(MediaPeriodId loadingMediaPerio bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -421,7 +409,6 @@ public PlaybackInfo copyWithPlayWhenReady( bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -451,38 +438,6 @@ public PlaybackInfo copyWithPlaybackParameters(PlaybackParameters playbackParame bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, - sleepingForOffload); - } - - /** - * Copies playback info with new offloadSchedulingEnabled. - * - * @param offloadSchedulingEnabled New offloadSchedulingEnabled state. See {@link - * #offloadSchedulingEnabled}. - * @return Copied playback info with new offload scheduling state. - */ - @CheckResult - public PlaybackInfo copyWithOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { - return new PlaybackInfo( - timeline, - periodId, - requestedContentPositionUs, - discontinuityStartPositionUs, - playbackState, - playbackError, - isLoading, - trackGroups, - trackSelectorResult, - staticMetadata, - loadingMediaPeriodId, - playWhenReady, - playbackSuppressionReason, - playbackParameters, - bufferedPositionUs, - totalBufferedDurationUs, - positionUs, - offloadSchedulingEnabled, sleepingForOffload); } @@ -512,7 +467,6 @@ public PlaybackInfo copyWithSleepingForOffload(boolean sleepingForOffload) { bufferedPositionUs, totalBufferedDurationUs, positionUs, - offloadSchedulingEnabled, sleepingForOffload); } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java index c1a34adb680..b53d79c47ea 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioSink.java @@ -29,7 +29,6 @@ import android.media.AudioTrack; import android.media.PlaybackParams; import android.media.metrics.LogSessionId; -import android.os.ConditionVariable; import android.os.Handler; import android.os.SystemClock; import android.util.Pair; @@ -44,6 +43,8 @@ import androidx.media3.common.MimeTypes; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Clock; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; @@ -615,7 +616,8 @@ private DefaultAudioSink(Builder builder) { enableAudioTrackPlaybackParams = Util.SDK_INT >= 23 && builder.enableAudioTrackPlaybackParams; offloadMode = Util.SDK_INT >= 29 ? builder.offloadMode : OFFLOAD_MODE_DISABLED; audioTrackBufferSizeProvider = builder.audioTrackBufferSizeProvider; - releasingConditionVariable = new ConditionVariable(true); + releasingConditionVariable = new ConditionVariable(Clock.DEFAULT); + releasingConditionVariable.open(); audioTrackPositionTracker = new AudioTrackPositionTracker(new PositionTrackerListener()); channelMappingAudioProcessor = new ChannelMappingAudioProcessor(); trimmingAudioProcessor = new TrimmingAudioProcessor(); @@ -840,13 +842,15 @@ private void flushAudioProcessors() { } } - private void initializeAudioTrack() throws InitializationException { - // If we're asynchronously releasing a previous audio track then we block until it has been + private boolean initializeAudioTrack() throws InitializationException { + // If we're asynchronously releasing a previous audio track then we wait until it has been // released. This guarantees that we cannot end up in a state where we have multiple audio // track instances. Without this guarantee it would be possible, in extreme cases, to exhaust // the shared memory that's available for audio track buffers. This would in turn cause the // initialization of the audio track to fail. - releasingConditionVariable.block(); + if (!releasingConditionVariable.isOpen()) { + return false; + } audioTrack = buildAudioTrackWithRetry(); if (isOffloadedPlayback(audioTrack)) { @@ -874,6 +878,7 @@ private void initializeAudioTrack() throws InitializationException { } startMediaTimeUsNeedsInit = true; + return true; } @Override @@ -930,7 +935,10 @@ public boolean handleBuffer( if (!isAudioTrackInitialized()) { try { - initializeAudioTrack(); + if (!initializeAudioTrack()) { + // Not yet ready for initialization of a new AudioTrack. + return false; + } } catch (InitializationException e) { if (e.isRecoverable) { throw e; // Do not delay the exception if it can be recovered at higher level. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java index a435e6c9efb..745cdc5474c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecInfo.java @@ -317,7 +317,9 @@ private boolean isCodecProfileAndLevelSupported(Format format) { } for (CodecProfileLevel profileLevel : profileLevels) { - if (profileLevel.profile == profile && profileLevel.level >= level) { + if (profileLevel.profile == profile + && profileLevel.level >= level + && !needsProfileExcludedWorkaround(mimeType, profile)) { return true; } } @@ -831,4 +833,15 @@ private static final boolean needsRotatedVerticalResolutionWorkaround(String nam } return true; } + + /** + * Whether a profile is excluded from the list of supported profiles. This may happen when a + * device declares support for a profile it doesn't actually support. + */ + private static boolean needsProfileExcludedWorkaround(String mimeType, int profile) { + // See https://github.com/google/ExoPlayer/issues/3537 + return MimeTypes.VIDEO_H265.equals(mimeType) + && CodecProfileLevel.HEVCProfileMain10 == profile + && ("sailfish".equals(Util.DEVICE) || "marlin".equals(Util.DEVICE)); + } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java index f0a8cb11648..6a55a3a13e7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java @@ -282,6 +282,7 @@ public DefaultMediaSourceFactory clearLocalAdInsertionComponents() { */ public DefaultMediaSourceFactory setDataSourceFactory(DataSource.Factory dataSourceFactory) { this.dataSourceFactory = dataSourceFactory; + delegateFactoryLoader.setDataSourceFactory(dataSourceFactory); return this; } @@ -594,6 +595,7 @@ public void setDataSourceFactory(DataSource.Factory dataSourceFactory) { this.dataSourceFactory = dataSourceFactory; // TODO(b/233577470): Call MediaSource.Factory.setDataSourceFactory on each value when it // exists on the interface. + mediaSourceFactorySuppliers.clear(); mediaSourceFactories.clear(); } } @@ -627,6 +629,7 @@ private Supplier maybeLoadSupplier(@C.ContentType int conte } @Nullable Supplier mediaSourceFactorySupplier = null; + DataSource.Factory dataSourceFactory = checkNotNull(this.dataSourceFactory); try { Class clazz; switch (contentType) { @@ -634,19 +637,19 @@ private Supplier maybeLoadSupplier(@C.ContentType int conte clazz = Class.forName("androidx.media3.exoplayer.dash.DashMediaSource$Factory") .asSubclass(MediaSource.Factory.class); - mediaSourceFactorySupplier = () -> newInstance(clazz, checkNotNull(dataSourceFactory)); + mediaSourceFactorySupplier = () -> newInstance(clazz, dataSourceFactory); break; case C.CONTENT_TYPE_SS: clazz = Class.forName("androidx.media3.exoplayer.smoothstreaming.SsMediaSource$Factory") .asSubclass(MediaSource.Factory.class); - mediaSourceFactorySupplier = () -> newInstance(clazz, checkNotNull(dataSourceFactory)); + mediaSourceFactorySupplier = () -> newInstance(clazz, dataSourceFactory); break; case C.CONTENT_TYPE_HLS: clazz = Class.forName("androidx.media3.exoplayer.hls.HlsMediaSource$Factory") .asSubclass(MediaSource.Factory.class); - mediaSourceFactorySupplier = () -> newInstance(clazz, checkNotNull(dataSourceFactory)); + mediaSourceFactorySupplier = () -> newInstance(clazz, dataSourceFactory); break; case C.CONTENT_TYPE_RTSP: clazz = @@ -656,9 +659,7 @@ private Supplier maybeLoadSupplier(@C.ContentType int conte break; case C.CONTENT_TYPE_OTHER: mediaSourceFactorySupplier = - () -> - new ProgressiveMediaSource.Factory( - checkNotNull(dataSourceFactory), extractorsFactory); + () -> new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory); break; default: // Do nothing. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java index 026917b9a04..7471f3a2bdb 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.source; +import static androidx.media3.common.util.Assertions.checkNotNull; import static java.lang.Math.max; import static java.lang.Math.min; @@ -135,7 +136,7 @@ interface Listener { private boolean seenFirstTrackSelection; private boolean notifyDiscontinuity; private int enabledTrackCount; - private long length; + private boolean isLengthKnown; private long lastSeekPositionUs; private long pendingResetPositionUs; @@ -193,15 +194,13 @@ public ProgressiveMediaPeriod( onContinueLoadingRequestedRunnable = () -> { if (!released) { - Assertions.checkNotNull(callback) - .onContinueLoadingRequested(ProgressiveMediaPeriod.this); + checkNotNull(callback).onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; handler = Util.createHandlerForCurrentLooper(); sampleQueueTrackIds = new TrackId[0]; sampleQueues = new SampleQueue[0]; pendingResetPositionUs = C.TIME_UNSET; - length = C.LENGTH_UNSET; durationUs = C.TIME_UNSET; dataType = C.DATA_TYPE_MEDIA; } @@ -367,7 +366,7 @@ public boolean isLoading() { @Override public long getNextLoadPositionUs() { - return enabledTrackCount == 0 ? C.TIME_END_OF_SOURCE : getBufferedPositionUs(); + return getBufferedPositionUs(); } @Override @@ -383,8 +382,7 @@ public long readDiscontinuity() { @Override public long getBufferedPositionUs() { assertPrepared(); - boolean[] trackIsAudioVideoFlags = trackState.trackIsAudioVideoFlags; - if (loadingFinished) { + if (loadingFinished || enabledTrackCount == 0) { return C.TIME_END_OF_SOURCE; } else if (isPendingReset()) { return pendingResetPositionUs; @@ -394,14 +392,16 @@ public long getBufferedPositionUs() { // Ignore non-AV tracks, which may be sparse or poorly interleaved. int trackCount = sampleQueues.length; for (int i = 0; i < trackCount; i++) { - if (trackIsAudioVideoFlags[i] && !sampleQueues[i].isLastSampleQueued()) { + if (trackState.trackIsAudioVideoFlags[i] + && trackState.trackEnabledStates[i] + && !sampleQueues[i].isLastSampleQueued()) { largestQueuedTimestampUs = min(largestQueuedTimestampUs, sampleQueues[i].getLargestQueuedTimestampUs()); } } } if (largestQueuedTimestampUs == Long.MAX_VALUE) { - largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + largestQueuedTimestampUs = getLargestQueuedTimestampUs(/* includeDisabledTracks= */ false); } return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs @@ -537,7 +537,7 @@ private void maybeStartDeferredRetry(int track) { for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.reset(); } - Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + checkNotNull(callback).onContinueLoadingRequested(this); } private boolean suppressRead() { @@ -551,7 +551,8 @@ public void onLoadCompleted( ExtractingLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { if (durationUs == C.TIME_UNSET && seekMap != null) { boolean isSeekable = seekMap.isSeekable(); - long largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + long largestQueuedTimestampUs = + getLargestQueuedTimestampUs(/* includeDisabledTracks= */ true); durationUs = largestQueuedTimestampUs == Long.MIN_VALUE ? 0 @@ -578,9 +579,8 @@ public void onLoadCompleted( /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs); - copyLengthFromLoader(loadable); loadingFinished = true; - Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + checkNotNull(callback).onContinueLoadingRequested(this); } @Override @@ -607,12 +607,11 @@ public void onLoadCanceled( /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs); if (!released) { - copyLengthFromLoader(loadable); for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.reset(); } if (enabledTrackCount > 0) { - Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + checkNotNull(callback).onContinueLoadingRequested(this); } } } @@ -624,7 +623,6 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { - copyLengthFromLoader(loadable); StatsDataSource dataSource = loadable.dataSource; LoadEventInfo loadEventInfo = new LoadEventInfo( @@ -710,6 +708,10 @@ public void onUpstreamFormatChanged(Format format) { // Internal methods. + private void onLengthKnown() { + handler.post(() -> isLengthKnown = true); + } + private TrackOutput prepareTrackOutput(TrackId id) { int trackCount = sampleQueues.length; for (int i = 0; i < trackCount; i++) { @@ -733,7 +735,7 @@ private TrackOutput prepareTrackOutput(TrackId id) { private void setSeekMap(SeekMap seekMap) { this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs= */ C.TIME_UNSET); durationUs = seekMap.getDurationUs(); - isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; + isLive = !isLengthKnown && seekMap.getDurationUs() == C.TIME_UNSET; dataType = isLive ? C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE : C.DATA_TYPE_MEDIA; listener.onSourceInfoRefreshed(durationUs, seekMap.isSeekable(), isLive); if (!prepared) { @@ -755,7 +757,7 @@ private void maybeFinishPrepare() { TrackGroup[] trackArray = new TrackGroup[trackCount]; boolean[] trackIsAudioVideoFlags = new boolean[trackCount]; for (int i = 0; i < trackCount; i++) { - Format trackFormat = Assertions.checkNotNull(sampleQueues[i].getUpstreamFormat()); + Format trackFormat = checkNotNull(sampleQueues[i].getUpstreamFormat()); @Nullable String mimeType = trackFormat.sampleMimeType; boolean isAudio = MimeTypes.isAudio(mimeType); boolean isAudioVideo = isAudio || MimeTypes.isVideo(mimeType); @@ -786,13 +788,7 @@ private void maybeFinishPrepare() { } trackState = new TrackState(new TrackGroupArray(trackArray), trackIsAudioVideoFlags); prepared = true; - Assertions.checkNotNull(callback).onPrepared(this); - } - - private void copyLengthFromLoader(ExtractingLoadable loadable) { - if (length == C.LENGTH_UNSET) { - length = loadable.length; - } + checkNotNull(callback).onPrepared(this); } private void startLoading() { @@ -807,7 +803,7 @@ private void startLoading() { return; } loadable.setLoadPosition( - Assertions.checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position, + checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.setStartTimeUs(pendingResetPositionUs); @@ -840,7 +836,7 @@ private void startLoading() { * retry. */ private boolean configureRetry(ExtractingLoadable loadable, int currentExtractedSampleCount) { - if (length != C.LENGTH_UNSET || (seekMap != null && seekMap.getDurationUs() != C.TIME_UNSET)) { + if (isLengthKnown || (seekMap != null && seekMap.getDurationUs() != C.TIME_UNSET)) { // We're playing an on-demand stream. Resume the current loadable, which will // request data starting from the point it left off. extractedSamplesCountAtStartOfLoad = currentExtractedSampleCount; @@ -904,11 +900,13 @@ private int getExtractedSamplesCount() { return extractedSamplesCount; } - private long getLargestQueuedTimestampUs() { + private long getLargestQueuedTimestampUs(boolean includeDisabledTracks) { long largestQueuedTimestampUs = Long.MIN_VALUE; - for (SampleQueue sampleQueue : sampleQueues) { - largestQueuedTimestampUs = - max(largestQueuedTimestampUs, sampleQueue.getLargestQueuedTimestampUs()); + for (int i = 0; i < sampleQueues.length; i++) { + if (includeDisabledTracks || checkNotNull(trackState).trackEnabledStates[i]) { + largestQueuedTimestampUs = + max(largestQueuedTimestampUs, sampleQueues[i].getLargestQueuedTimestampUs()); + } } return largestQueuedTimestampUs; } @@ -920,8 +918,8 @@ private boolean isPendingReset() { @EnsuresNonNull({"trackState", "seekMap"}) private void assertPrepared() { Assertions.checkState(prepared); - Assertions.checkNotNull(trackState); - Assertions.checkNotNull(seekMap); + checkNotNull(trackState); + checkNotNull(seekMap); } private final class SampleStreamImpl implements SampleStream { @@ -970,7 +968,6 @@ public int skipData(long positionUs) { private boolean pendingExtractorSeek; private long seekTimeUs; private DataSpec dataSpec; - private long length; @Nullable private TrackOutput icyTrackOutput; private boolean seenIcyMetadata; @@ -988,7 +985,6 @@ public ExtractingLoadable( this.loadCondition = loadCondition; this.positionHolder = new PositionHolder(); this.pendingExtractorSeek = true; - this.length = C.LENGTH_UNSET; loadTaskId = LoadEventInfo.getNewId(); dataSpec = buildDataSpec(/* position= */ 0); } @@ -1007,9 +1003,10 @@ public void load() throws IOException { try { long position = positionHolder.position; dataSpec = buildDataSpec(position); - length = dataSource.open(dataSpec); + long length = dataSource.open(dataSpec); if (length != C.LENGTH_UNSET) { length += position; + onLengthKnown(); } icyHeaders = IcyHeaders.parse(dataSource.getResponseHeaders()); DataSource extractorDataSource = dataSource; @@ -1065,9 +1062,12 @@ public void load() throws IOException { public void onIcyMetadata(ParsableByteArray metadata) { // Always output the first ICY metadata at the start time. This helps minimize any delay // between the start of playback and the first ICY metadata event. - long timeUs = !seenIcyMetadata ? seekTimeUs : max(getLargestQueuedTimestampUs(), seekTimeUs); + long timeUs = + !seenIcyMetadata + ? seekTimeUs + : max(getLargestQueuedTimestampUs(/* includeDisabledTracks= */ true), seekTimeUs); int length = metadata.bytesLeft(); - TrackOutput icyTrackOutput = Assertions.checkNotNull(this.icyTrackOutput); + TrackOutput icyTrackOutput = checkNotNull(this.icyTrackOutput); icyTrackOutput.sampleData(metadata, length); icyTrackOutput.sampleMetadata( timeUs, C.BUFFER_FLAG_KEY_FRAME, length, /* offset= */ 0, /* cryptoData= */ null); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextOutput.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextOutput.java index 5485452d965..6e12cd57fbc 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextOutput.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextOutput.java @@ -39,7 +39,7 @@ default void onCues(List cues) {} * Called when there is a change in the {@link CueGroup}. * *

Both {@link #onCues(List)} and {@link #onCues(CueGroup)} are called when there is a change - * in the cues You should only implement one or the other. + * in the cues. You should only implement one or the other. */ void onCues(CueGroup cueGroup); } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java index 7773fa039e4..6c091844a4a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/video/MediaCodecVideoRenderer.java @@ -629,7 +629,7 @@ private void setOutput(@Nullable Object output) throws ExoPlaybackException { surface = placeholderSurface; } else { MediaCodecInfo codecInfo = getCodecInfo(); - if (codecInfo != null && shouldUseDummySurface(codecInfo)) { + if (codecInfo != null && shouldUsePlaceholderSurface(codecInfo)) { placeholderSurface = PlaceholderSurface.newInstanceV17(context, codecInfo.secure); surface = placeholderSurface; } @@ -675,7 +675,7 @@ private void setOutput(@Nullable Object output) throws ExoPlaybackException { @Override protected boolean shouldInitCodec(MediaCodecInfo codecInfo) { - return surface != null || shouldUseDummySurface(codecInfo); + return surface != null || shouldUsePlaceholderSurface(codecInfo); } @Override @@ -706,7 +706,7 @@ protected MediaCodecAdapter.Configuration getMediaCodecConfiguration( deviceNeedsNoPostProcessWorkaround, tunneling ? tunnelingAudioSessionId : C.AUDIO_SESSION_ID_UNSET); if (surface == null) { - if (!shouldUseDummySurface(codecInfo)) { + if (!shouldUsePlaceholderSurface(codecInfo)) { throw new IllegalStateException(); } if (placeholderSurface == null) { @@ -1333,7 +1333,7 @@ protected void renderOutputBufferV21( maybeNotifyRenderedFirstFrame(); } - private boolean shouldUseDummySurface(MediaCodecInfo codecInfo) { + private boolean shouldUsePlaceholderSurface(MediaCodecInfo codecInfo) { return Util.SDK_INT >= 23 && !tunneling && !codecNeedsSetOutputSurfaceWorkaround(codecInfo.name) @@ -1572,7 +1572,7 @@ protected CodecMaxValues getCodecMaxValues( } if (haveUnknownDimensions) { Log.w(TAG, "Resolutions unknown. Codec max resolution: " + maxWidth + "x" + maxHeight); - Point codecMaxSize = getCodecMaxSize(codecInfo, format); + @Nullable Point codecMaxSize = getCodecMaxSize(codecInfo, format); if (codecMaxSize != null) { maxWidth = max(maxWidth, codecMaxSize.x); maxHeight = max(maxHeight, codecMaxSize.y); @@ -1600,8 +1600,10 @@ protected MediaCodecDecoderException createDecoderException( * * @param codecInfo Information about the {@link MediaCodec} being configured. * @param format The {@link Format} for which the codec is being configured. - * @return The maximum video size to use, or null if the size of {@code format} should be used. + * @return The maximum video size to use, or {@code null} if the size of {@code format} should be + * used. */ + @Nullable private static Point getCodecMaxSize(MediaCodecInfo codecInfo, Format format) { boolean isVerticalVideo = format.height > format.width; int formatLongEdgePx = isVerticalVideo ? format.height : format.width; diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 784f6c23df3..7c1382bba9d 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -53,13 +53,13 @@ import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; import static androidx.media3.test.utils.TestUtil.assertTimelinesSame; +import static androidx.media3.test.utils.TestUtil.timelinesAreSame; import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.playUntilPosition; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.playUntilStartOfMediaItem; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlaybackState; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPositionDiscontinuity; -import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilReceiveOffloadSchedulingEnabledNewState; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilTimelineChanged; import static com.google.common.truth.Truth.assertThat; @@ -125,6 +125,7 @@ import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSourceEventListener; +import androidx.media3.exoplayer.source.ShuffleOrder; import androidx.media3.exoplayer.source.SinglePeriodTimeline; import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource; @@ -157,7 +158,6 @@ import androidx.media3.test.utils.FakeTrackSelection; import androidx.media3.test.utils.FakeTrackSelector; import androidx.media3.test.utils.FakeVideoRenderer; -import androidx.media3.test.utils.NoUidTimeline; import androidx.media3.test.utils.TestExoPlayerBuilder; import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; @@ -6512,6 +6512,53 @@ public void run(ExoPlayer player) { assertThat(positionAfterSetShuffleOrder.get()).isAtLeast(5000); } + @Test + public void setShuffleOrder_notifiesTimelineChanged() throws Exception { + ExoPlayer player = + new TestExoPlayerBuilder(context) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + // No callback expected for this call, because the (empty) timeline doesn't change. We start + // with a deterministic shuffle order, to ensure when we call setShuffleOrder again below the + // order is definitely different (otherwise the test is flaky when the existing shuffle order + // matches the shuffle order passed in below). + player.setShuffleOrder(new FakeShuffleOrder(0)); + player.setMediaSources( + ImmutableList.of(new FakeMediaSource(), new FakeMediaSource(), new FakeMediaSource())); + Player.Listener mockListener = mock(Player.Listener.class); + player.addListener(mockListener); + player.prepare(); + TestPlayerRunHelper.playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 5000); + player.play(); + ShuffleOrder.DefaultShuffleOrder newShuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(player.getMediaItemCount(), /* randomSeed= */ 5); + player.setShuffleOrder(newShuffleOrder); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + ArgumentCaptor timelineCaptor = ArgumentCaptor.forClass(Timeline.class); + verify(mockListener) + .onTimelineChanged( + timelineCaptor.capture(), eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + + Timeline capturedTimeline = Iterables.getOnlyElement(timelineCaptor.getAllValues()); + List newShuffleOrderIndexes = new ArrayList<>(newShuffleOrder.getLength()); + for (int i = newShuffleOrder.getFirstIndex(); + i != C.INDEX_UNSET; + i = newShuffleOrder.getNextIndex(i)) { + newShuffleOrderIndexes.add(i); + } + List capturedTimelineShuffleIndexes = new ArrayList<>(newShuffleOrder.getLength()); + for (int i = capturedTimeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true); + i != C.INDEX_UNSET; + i = + capturedTimeline.getNextWindowIndex( + i, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) { + capturedTimelineShuffleIndexes.add(i); + } + assertThat(capturedTimelineShuffleIndexes).isEqualTo(newShuffleOrderIndexes); + } + @Test public void setMediaSources_empty_whenEmpty_correctMaskingMediaItemIndex() throws Exception { final int[] currentMediaItemIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; @@ -9635,47 +9682,16 @@ protected void onEnabled(boolean joining, boolean mayRenderStartOfStream) } @Test - public void enableOffloadSchedulingWhileIdle_isToggled_isReported() throws Exception { + public void enableOffloadScheduling_isReported() throws Exception { ExoPlayer player = new TestExoPlayerBuilder(context).build(); + ExoPlayer.AudioOffloadListener mockListener = mock(ExoPlayer.AudioOffloadListener.class); + player.addAudioOffloadListener(mockListener); player.experimentalSetOffloadSchedulingEnabled(true); - assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); + verify(mockListener).onExperimentalOffloadSchedulingEnabledChanged(true); player.experimentalSetOffloadSchedulingEnabled(false); - assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); - } - - @Test - public void enableOffloadSchedulingWhilePlaying_isToggled_isReported() throws Exception { - FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); - ExoPlayer player = new TestExoPlayerBuilder(context).setRenderers(sleepRenderer).build(); - Timeline timeline = new FakeTimeline(); - player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); - player.prepare(); - player.play(); - - player.experimentalSetOffloadSchedulingEnabled(true); - assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isTrue(); - - player.experimentalSetOffloadSchedulingEnabled(false); - assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); - } - - @Test - public void enableOffloadSchedulingWhileSleepingForOffload_isDisabled_isReported() - throws Exception { - FakeSleepRenderer sleepRenderer = new FakeSleepRenderer(C.TRACK_TYPE_AUDIO).sleepOnNextRender(); - ExoPlayer player = new TestExoPlayerBuilder(context).setRenderers(sleepRenderer).build(); - Timeline timeline = new FakeTimeline(); - player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT)); - player.experimentalSetOffloadSchedulingEnabled(true); - player.prepare(); - player.play(); - runUntilSleepingForOffload(player, /* expectedSleepForOffload= */ true); - - player.experimentalSetOffloadSchedulingEnabled(false); - - assertThat(runUntilReceiveOffloadSchedulingEnabledNewState(player)).isFalse(); + verify(mockListener).onExperimentalOffloadSchedulingEnabledChanged(false); } @Test @@ -12296,6 +12312,6 @@ public Loader.LoadErrorAction onLoadError( * Returns an argument matcher for {@link Timeline} instances that ignores period and window uids. */ private static ArgumentMatcher noUid(Timeline timeline) { - return argument -> new NoUidTimeline(timeline).equals(new NoUidTimeline(argument)); + return argument -> timelinesAreSame(argument, timeline); } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java index 155450ec7e1..cb059bc241c 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/MediaPeriodQueueTest.java @@ -1112,7 +1112,6 @@ private void setupTimeline(Timeline timeline) { /* bufferedPositionUs= */ 0, /* totalBufferedDurationUs= */ 0, /* positionUs= */ 0, - /* offloadSchedulingEnabled= */ false, /* sleepingForOffload= */ false); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/Mp4PlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/Mp4PlaybackTest.java index be3ccd914bf..b7ad234f18f 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/Mp4PlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/Mp4PlaybackTest.java @@ -57,6 +57,7 @@ public static ImmutableList mediaSamples() { "sample_eac3joc.mp4", "sample_fragmented.mp4", "sample_fragmented_seekable.mp4", + "sample_fragmented_large_bitrates.mp4", "sample_fragmented_sei.mp4", "sample_mdat_too_long.mp4", "sample.mp4", diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java index 36c3695193d..6c4e9bee10b 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/manifest/DashManifestParser.java @@ -599,6 +599,9 @@ protected AdaptationSet buildAdaptationSet( case "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": uuid = C.WIDEVINE_UUID; break; + case "urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e": + uuid = C.CLEARKEY_UUID; + break; default: break; } @@ -606,7 +609,9 @@ protected AdaptationSet buildAdaptationSet( do { xpp.next(); - if (XmlPullParserUtil.isStartTag(xpp, "ms:laurl")) { + if (XmlPullParserUtil.isStartTag(xpp, "clearkey:Laurl") && xpp.next() == XmlPullParser.TEXT) { + licenseServerUrl = xpp.getText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "ms:laurl")) { licenseServerUrl = xpp.getAttributeValue(null, "licenseUrl"); } else if (data == null && XmlPullParserUtil.isStartTagIgnorePrefix(xpp, "pssh") @@ -853,6 +858,7 @@ protected Representation buildRepresentation( ArrayList drmSchemeDatas = representationInfo.drmSchemeDatas; drmSchemeDatas.addAll(extraDrmSchemeDatas); if (!drmSchemeDatas.isEmpty()) { + fillInClearKeyInformation(drmSchemeDatas); filterRedundantIncompleteSchemeDatas(drmSchemeDatas); formatBuilder.setDrmInitData(new DrmInitData(drmSchemeType, drmSchemeDatas)); } @@ -1660,6 +1666,32 @@ private static void filterRedundantIncompleteSchemeDatas(ArrayList s } } + private static void fillInClearKeyInformation(ArrayList schemeDatas) { + // Find and remove ClearKey information. + @Nullable String clearKeyLicenseServerUrl = null; + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.CLEARKEY_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl != null) { + clearKeyLicenseServerUrl = schemeData.licenseServerUrl; + schemeDatas.remove(i); + break; + } + } + if (clearKeyLicenseServerUrl == null) { + return; + } + // Fill in the ClearKey information into the existing PSSH schema data if applicable. + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.COMMON_PSSH_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl == null) { + schemeDatas.set( + i, + new SchemeData( + C.CLEARKEY_UUID, clearKeyLicenseServerUrl, schemeData.mimeType, schemeData.data)); + } + } + } + /** * Derives a sample mimeType from a container mimeType and codecs attribute. * diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultMediaSourceFactoryTest.java index b6d4ac102f5..77c1dde3112 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultMediaSourceFactoryTest.java @@ -15,16 +15,21 @@ */ package androidx.media3.exoplayer.dash; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.google.common.truth.Truth.assertThat; import android.content.Context; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.FakeDataSource; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -82,4 +87,53 @@ public void getSupportedTypes_dashModule_containsTypeDash() { assertThat(supportedTypes).asList().containsExactly(C.CONTENT_TYPE_OTHER, C.CONTENT_TYPE_DASH); } + + @Test + public void createMediaSource_withSetDataSourceFactory_usesDataSourceFactory() throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setDataSourceFactory(() -> fakeDataSource); + + prepareDashUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + @Test + public void + createMediaSource_usingDefaultDataSourceFactoryAndSetDataSourceFactory_usesUpdatesDataSourceFactory() + throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + + // Use default DataSource.Factory first. + prepareDashUrlAndWaitForPrepareError(defaultMediaSourceFactory); + defaultMediaSourceFactory.setDataSourceFactory(() -> fakeDataSource); + prepareDashUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + private static void prepareDashUrlAndWaitForPrepareError( + DefaultMediaSourceFactory defaultMediaSourceFactory) throws Exception { + MediaSource mediaSource = + defaultMediaSourceFactory.createMediaSource(MediaItem.fromUri(URI_MEDIA + "/file.mpd")); + getInstrumentation() + .runOnMainSync( + () -> + mediaSource.prepareSource( + (source, timeline) -> {}, /* mediaTransferListener= */ null, PlayerId.UNSET)); + // We don't expect this to prepare successfully. + RobolectricUtil.runMainLooperUntil( + () -> { + try { + mediaSource.maybeThrowSourceInfoRefreshError(); + return false; + } catch (IOException e) { + return true; + } + }); + } } diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java index 667f55c197e..76193227696 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/manifest/DashManifestParserTest.java @@ -20,6 +20,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.DrmInitData; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Util; @@ -79,6 +80,8 @@ public class DashManifestParserTest { "media/mpd/sample_mpd_service_description_low_latency_only_playback_rates"; private static final String SAMPLE_MPD_SERVICE_DESCRIPTION_LOW_LATENCY_ONLY_TARGET_LATENCY = "media/mpd/sample_mpd_service_description_low_latency_only_target_latency"; + private static final String SAMPLE_MPD_CLEAR_KEY_LICENSE_URL = + "media/mpd/sample_mpd_clear_key_license_url"; private static final String NEXT_TAG_NAME = "Next"; private static final String NEXT_TAG = "<" + NEXT_TAG_NAME + "/>"; @@ -880,6 +883,37 @@ public void serviceDescriptionElement_noServiceDescription_isNullInManifest() th assertThat(manifest.serviceDescription).isNull(); } + @Test + public void contentProtections_withClearKeyLicenseUrl() throws IOException { + DashManifestParser parser = new DashManifestParser(); + + DashManifest manifest = + parser.parse( + Uri.parse("https://example.com/test.mpd"), + TestUtil.getInputStream( + ApplicationProvider.getApplicationContext(), SAMPLE_MPD_CLEAR_KEY_LICENSE_URL)); + + assertThat(manifest.getPeriodCount()).isEqualTo(1); + Period period = manifest.getPeriod(0); + assertThat(period.adaptationSets).hasSize(2); + AdaptationSet adaptationSet0 = period.adaptationSets.get(0); + AdaptationSet adaptationSet1 = period.adaptationSets.get(1); + assertThat(adaptationSet0.representations).hasSize(1); + assertThat(adaptationSet1.representations).hasSize(1); + Representation representation0 = adaptationSet0.representations.get(0); + Representation representation1 = adaptationSet1.representations.get(0); + assertThat(representation0.format.drmInitData.schemeType).isEqualTo("cenc"); + assertThat(representation1.format.drmInitData.schemeType).isEqualTo("cenc"); + assertThat(representation0.format.drmInitData.schemeDataCount).isEqualTo(1); + assertThat(representation1.format.drmInitData.schemeDataCount).isEqualTo(1); + DrmInitData.SchemeData schemeData0 = representation0.format.drmInitData.get(0); + DrmInitData.SchemeData schemeData1 = representation1.format.drmInitData.get(0); + assertThat(schemeData0.uuid).isEqualTo(C.CLEARKEY_UUID); + assertThat(schemeData1.uuid).isEqualTo(C.CLEARKEY_UUID); + assertThat(schemeData0.licenseServerUrl).isEqualTo("https://testserver1.test/AcquireLicense"); + assertThat(schemeData1.licenseServerUrl).isEqualTo("https://testserver2.test/AcquireLicense"); + } + private static List buildCea608AccessibilityDescriptors(String value) { return Collections.singletonList(new Descriptor("urn:scte:dash:cc:cea-608:2015", value, null)); } diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/DefaultMediaSourceFactoryTest.java index 2a2ff66b280..8062cff051a 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/DefaultMediaSourceFactoryTest.java @@ -15,16 +15,21 @@ */ package androidx.media3.exoplayer.hls; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.google.common.truth.Truth.assertThat; import android.content.Context; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.FakeDataSource; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -82,4 +87,53 @@ public void getSupportedTypes_hlsModule_containsTypeHls() { assertThat(supportedTypes).asList().containsExactly(C.CONTENT_TYPE_OTHER, C.CONTENT_TYPE_HLS); } + + @Test + public void createMediaSource_withSetDataSourceFactory_usesDataSourceFactory() throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setDataSourceFactory(() -> fakeDataSource); + + prepareHlsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + @Test + public void + createMediaSource_usingDefaultDataSourceFactoryAndSetDataSourceFactory_usesUpdatesDataSourceFactory() + throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + + // Use default DataSource.Factory first. + prepareHlsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + defaultMediaSourceFactory.setDataSourceFactory(() -> fakeDataSource); + prepareHlsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + private static void prepareHlsUrlAndWaitForPrepareError( + DefaultMediaSourceFactory defaultMediaSourceFactory) throws Exception { + MediaSource mediaSource = + defaultMediaSourceFactory.createMediaSource(MediaItem.fromUri(URI_MEDIA + "/file.m3u8")); + getInstrumentation() + .runOnMainSync( + () -> + mediaSource.prepareSource( + (source, timeline) -> {}, /* mediaTransferListener= */ null, PlayerId.UNSET)); + // We don't expect this to prepare successfully. + RobolectricUtil.runMainLooperUntil( + () -> { + try { + mediaSource.maybeThrowSourceInfoRefreshError(); + return false; + } catch (IOException e) { + return true; + } + }); + } } diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8Reader.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8Reader.java index 72b739edd23..31bc245f8e7 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8Reader.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8Reader.java @@ -15,6 +15,8 @@ */ package androidx.media3.exoplayer.rtsp.reader; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkStateNotNull; import androidx.media3.common.C; @@ -51,6 +53,8 @@ /** The combined size of a sample that is fragmented into multiple RTP packets. */ private int fragmentedSampleSizeBytes; + private long fragmentedSampleTimeUs; + private long startTimeOffsetUs; /** * Whether the first packet of one VP8 frame is received. A VP8 frame can be split into two RTP @@ -67,6 +71,7 @@ public RtpVp8Reader(RtpPayloadFormat payloadFormat) { firstReceivedTimestamp = C.TIME_UNSET; previousSequenceNumber = C.INDEX_UNSET; fragmentedSampleSizeBytes = C.LENGTH_UNSET; + fragmentedSampleTimeUs = C.TIME_UNSET; // The start time offset must be 0 until the first seek. startTimeOffsetUs = 0; gotFirstPacketOfVp8Frame = false; @@ -81,7 +86,10 @@ public void createTracks(ExtractorOutput extractorOutput, int trackId) { } @Override - public void onReceivingFirstPacket(long timestamp, int sequenceNumber) {} + public void onReceivingFirstPacket(long timestamp, int sequenceNumber) { + checkState(firstReceivedTimestamp == C.TIME_UNSET); + firstReceivedTimestamp = timestamp; + } @Override public void consume( @@ -113,21 +121,16 @@ public void consume( int fragmentSize = data.bytesLeft(); trackOutput.sampleData(data, fragmentSize); - fragmentedSampleSizeBytes += fragmentSize; + if (fragmentedSampleSizeBytes == C.LENGTH_UNSET) { + fragmentedSampleSizeBytes = fragmentSize; + } else { + fragmentedSampleSizeBytes += fragmentSize; + } + + fragmentedSampleTimeUs = toSampleUs(startTimeOffsetUs, timestamp, firstReceivedTimestamp); if (rtpMarker) { - if (firstReceivedTimestamp == C.TIME_UNSET) { - firstReceivedTimestamp = timestamp; - } - long timeUs = toSampleUs(startTimeOffsetUs, timestamp, firstReceivedTimestamp); - trackOutput.sampleMetadata( - timeUs, - isKeyFrame ? C.BUFFER_FLAG_KEY_FRAME : 0, - fragmentedSampleSizeBytes, - /* offset= */ 0, - /* cryptoData= */ null); - fragmentedSampleSizeBytes = C.LENGTH_UNSET; - gotFirstPacketOfVp8Frame = false; + outputSampleMetadataForFragmentedPackets(); } previousSequenceNumber = sequenceNumber; } @@ -147,18 +150,18 @@ public void seek(long nextRtpTimestamp, long timeUs) { private boolean validateVp8Descriptor(ParsableByteArray payload, int packetSequenceNumber) { // VP8 Payload Descriptor is defined in RFC7741 Section 4.2. int header = payload.readUnsignedByte(); - if (!gotFirstPacketOfVp8Frame) { - // TODO(b/198620566) Consider using ParsableBitArray. - // For start of VP8 partition S=1 and PID=0 as per RFC7741 Section 4.2. - if ((header & 0x10) != 0x1 || (header & 0x07) != 0) { - Log.w(TAG, "RTP packet is not the start of a new VP8 partition, skipping."); - return false; + // TODO(b/198620566) Consider using ParsableBitArray. + // For start of VP8 partition S=1 and PID=0 as per RFC7741 Section 4.2. + if ((header & 0x10) == 0x10 && (header & 0x07) == 0) { + if (gotFirstPacketOfVp8Frame && fragmentedSampleSizeBytes > 0) { + // Received new VP8 fragment, output data of previous fragment to decoder. + outputSampleMetadataForFragmentedPackets(); } gotFirstPacketOfVp8Frame = true; - } else { + } else if (gotFirstPacketOfVp8Frame) { // Check that this packet is in the sequence of the previous packet. int expectedSequenceNumber = RtpPacket.getNextSequenceNumber(previousSequenceNumber); - if (packetSequenceNumber != expectedSequenceNumber) { + if (packetSequenceNumber < expectedSequenceNumber) { Log.w( TAG, Util.formatInvariant( @@ -167,6 +170,9 @@ private boolean validateVp8Descriptor(ParsableByteArray payload, int packetSeque expectedSequenceNumber, packetSequenceNumber)); return false; } + } else { + Log.w(TAG, "RTP packet is not the start of a new VP8 partition, skipping."); + return false; } // Check if optional X header is present. @@ -195,6 +201,24 @@ private boolean validateVp8Descriptor(ParsableByteArray payload, int packetSeque return true; } + /** + * Outputs sample metadata of the received fragmented packets. + * + *

Call this method only after receiving an end of a VP8 partition. + */ + private void outputSampleMetadataForFragmentedPackets() { + checkNotNull(trackOutput) + .sampleMetadata( + fragmentedSampleTimeUs, + isKeyFrame ? C.BUFFER_FLAG_KEY_FRAME : 0, + fragmentedSampleSizeBytes, + /* offset= */ 0, + /* cryptoData= */ null); + fragmentedSampleSizeBytes = 0; + fragmentedSampleTimeUs = C.TIME_UNSET; + gotFirstPacketOfVp8Frame = false; + } + private static long toSampleUs( long startTimeOffsetUs, long rtpTimestamp, long firstReceivedRtpTimestamp) { return startTimeOffsetUs diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8ReaderTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8ReaderTest.java new file mode 100644 index 00000000000..61f80c6c2de --- /dev/null +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8ReaderTest.java @@ -0,0 +1,203 @@ +/* + * Copyright 2022 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 androidx.media3.exoplayer.rtsp.reader; + +import static androidx.media3.common.util.Util.getBytesFromHexString; +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.ParsableByteArray; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.rtsp.RtpPacket; +import androidx.media3.exoplayer.rtsp.RtpPayloadFormat; +import androidx.media3.test.utils.FakeExtractorOutput; +import androidx.media3.test.utils.FakeTrackOutput; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableMap; +import com.google.common.primitives.Bytes; +import java.util.Arrays; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtpVp8Reader}. */ +@RunWith(AndroidJUnit4.class) +public final class RtpVp8ReaderTest { + + /** VP9 uses a 90 KHz media clock (RFC7741 Section 4.1). */ + private static final long MEDIA_CLOCK_FREQUENCY = 90_000; + + private static final byte[] PARTITION_1 = getBytesFromHexString("000102030405060708090A0B0C0D0E"); + // 000102030405060708090A + private static final byte[] PARTITION_1_FRAGMENT_1 = + Arrays.copyOf(PARTITION_1, /* newLength= */ 11); + // 0B0C0D0E + private static final byte[] PARTITION_1_FRAGMENT_2 = + Arrays.copyOfRange(PARTITION_1, /* from= */ 11, /* to= */ 15); + private static final long PARTITION_1_RTP_TIMESTAMP = 2599168056L; + private static final RtpPacket PACKET_PARTITION_1_FRAGMENT_1 = + new RtpPacket.Builder() + .setTimestamp(PARTITION_1_RTP_TIMESTAMP) + .setSequenceNumber(40289) + .setMarker(false) + .setPayloadData(Bytes.concat(getBytesFromHexString("10"), PARTITION_1_FRAGMENT_1)) + .build(); + private static final RtpPacket PACKET_PARTITION_1_FRAGMENT_2 = + new RtpPacket.Builder() + .setTimestamp(PARTITION_1_RTP_TIMESTAMP) + .setSequenceNumber(40290) + .setMarker(false) + .setPayloadData(Bytes.concat(getBytesFromHexString("00"), PARTITION_1_FRAGMENT_2)) + .build(); + + private static final byte[] PARTITION_2 = getBytesFromHexString("0D0C0B0A09080706050403020100"); + // 0D0C0B0A090807060504 + private static final byte[] PARTITION_2_FRAGMENT_1 = + Arrays.copyOf(PARTITION_2, /* newLength= */ 10); + // 03020100 + private static final byte[] PARTITION_2_FRAGMENT_2 = + Arrays.copyOfRange(PARTITION_2, /* from= */ 10, /* to= */ 14); + private static final long PARTITION_2_RTP_TIMESTAMP = 2599168344L; + private static final RtpPacket PACKET_PARTITION_2_FRAGMENT_1 = + new RtpPacket.Builder() + .setTimestamp(PARTITION_2_RTP_TIMESTAMP) + .setSequenceNumber(40291) + .setMarker(false) + .setPayloadData(Bytes.concat(getBytesFromHexString("10"), PARTITION_2_FRAGMENT_1)) + .build(); + private static final RtpPacket PACKET_PARTITION_2_FRAGMENT_2 = + new RtpPacket.Builder() + .setTimestamp(PARTITION_2_RTP_TIMESTAMP) + .setSequenceNumber(40292) + .setMarker(true) + .setPayloadData( + Bytes.concat( + getBytesFromHexString("80"), + // Optional header. + getBytesFromHexString("D6AA953961"), + PARTITION_2_FRAGMENT_2)) + .build(); + private static final long PARTITION_2_PRESENTATION_TIMESTAMP_US = + Util.scaleLargeTimestamp( + (PARTITION_2_RTP_TIMESTAMP - PARTITION_1_RTP_TIMESTAMP), + /* multiplier= */ C.MICROS_PER_SECOND, + /* divisor= */ MEDIA_CLOCK_FREQUENCY); + + private FakeExtractorOutput extractorOutput; + + @Before + public void setUp() { + extractorOutput = + new FakeExtractorOutput( + (id, type) -> new FakeTrackOutput(/* deduplicateConsecutiveFormats= */ true)); + } + + @Test + public void consume_validPackets() { + RtpVp8Reader vp8Reader = createVp8Reader(); + + vp8Reader.createTracks(extractorOutput, /* trackId= */ 0); + vp8Reader.onReceivingFirstPacket( + PACKET_PARTITION_1_FRAGMENT_1.timestamp, PACKET_PARTITION_1_FRAGMENT_1.sequenceNumber); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_2); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_2); + + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(PARTITION_1); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(PARTITION_2); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(PARTITION_2_PRESENTATION_TIMESTAMP_US); + } + + @Test + public void consume_fragmentedFrameMissingFirstFragment() { + RtpVp8Reader vp8Reader = createVp8Reader(); + + vp8Reader.createTracks(extractorOutput, /* trackId= */ 0); + // First packet timing information is transmitted over RTSP, not RTP. + vp8Reader.onReceivingFirstPacket( + PACKET_PARTITION_1_FRAGMENT_1.timestamp, PACKET_PARTITION_1_FRAGMENT_1.sequenceNumber); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_2); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_2); + + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + assertThat(trackOutput.getSampleCount()).isEqualTo(1); + assertThat(trackOutput.getSampleData(0)).isEqualTo(PARTITION_2); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(PARTITION_2_PRESENTATION_TIMESTAMP_US); + } + + @Test + public void consume_fragmentedFrameMissingBoundaryFragment() { + RtpVp8Reader vp8Reader = createVp8Reader(); + + vp8Reader.createTracks(extractorOutput, /* trackId= */ 0); + vp8Reader.onReceivingFirstPacket( + PACKET_PARTITION_1_FRAGMENT_1.timestamp, PACKET_PARTITION_1_FRAGMENT_1.sequenceNumber); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_2); + + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(PARTITION_1_FRAGMENT_1); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(PARTITION_2); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(PARTITION_2_PRESENTATION_TIMESTAMP_US); + } + + @Test + public void consume_outOfOrderFragmentedFrame() { + RtpVp8Reader vp8Reader = createVp8Reader(); + + vp8Reader.createTracks(extractorOutput, /* trackId= */ 0); + vp8Reader.onReceivingFirstPacket( + PACKET_PARTITION_1_FRAGMENT_1.timestamp, PACKET_PARTITION_1_FRAGMENT_1.sequenceNumber); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_1); + consume(vp8Reader, PACKET_PARTITION_1_FRAGMENT_2); + consume(vp8Reader, PACKET_PARTITION_2_FRAGMENT_2); + + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(PARTITION_1_FRAGMENT_1); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(PARTITION_2); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(PARTITION_2_PRESENTATION_TIMESTAMP_US); + } + + private static RtpVp8Reader createVp8Reader() { + return new RtpVp8Reader( + new RtpPayloadFormat( + new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_VP8).build(), + /* rtpPayloadType= */ 96, + /* clockRate= */ (int) MEDIA_CLOCK_FREQUENCY, + /* fmtpParameters= */ ImmutableMap.of())); + } + + private static void consume(RtpVp8Reader vp8Reader, RtpPacket rtpPacket) { + vp8Reader.consume( + new ParsableByteArray(rtpPacket.payloadData), + rtpPacket.timestamp, + rtpPacket.sequenceNumber, + rtpPacket.marker); + } +} diff --git a/libraries/exoplayer_smoothstreaming/build.gradle b/libraries/exoplayer_smoothstreaming/build.gradle index a379d25558d..4b145ec6b3c 100644 --- a/libraries/exoplayer_smoothstreaming/build.gradle +++ b/libraries/exoplayer_smoothstreaming/build.gradle @@ -29,6 +29,7 @@ dependencies { compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + testImplementation project(modulePrefix + 'test-utils-robolectric') testImplementation project(modulePrefix + 'test-utils') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultMediaSourceFactoryTest.java b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultMediaSourceFactoryTest.java index f5a205fcbe7..4036fb94728 100644 --- a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultMediaSourceFactoryTest.java +++ b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultMediaSourceFactoryTest.java @@ -15,16 +15,21 @@ */ package androidx.media3.exoplayer.smoothstreaming; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static com.google.common.truth.Truth.assertThat; import android.content.Context; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; +import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.test.utils.FakeDataSource; +import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; @@ -93,4 +98,53 @@ public void getSupportedTypes_smoothstreamingModule_containsTypeSS() { assertThat(supportedTypes).asList().containsExactly(C.CONTENT_TYPE_OTHER, C.CONTENT_TYPE_SS); } + + @Test + public void createMediaSource_withSetDataSourceFactory_usesDataSourceFactory() throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()) + .setDataSourceFactory(() -> fakeDataSource); + + prepareSsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + @Test + public void + createMediaSource_usingDefaultDataSourceFactoryAndSetDataSourceFactory_usesUpdatesDataSourceFactory() + throws Exception { + FakeDataSource fakeDataSource = new FakeDataSource(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + + // Use default DataSource.Factory first. + prepareSsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + defaultMediaSourceFactory.setDataSourceFactory(() -> fakeDataSource); + prepareSsUrlAndWaitForPrepareError(defaultMediaSourceFactory); + + assertThat(fakeDataSource.getAndClearOpenedDataSpecs()).asList().isNotEmpty(); + } + + private static void prepareSsUrlAndWaitForPrepareError( + DefaultMediaSourceFactory defaultMediaSourceFactory) throws Exception { + MediaSource mediaSource = + defaultMediaSourceFactory.createMediaSource(MediaItem.fromUri(URI_MEDIA + "/file.ism")); + getInstrumentation() + .runOnMainSync( + () -> + mediaSource.prepareSource( + (source, timeline) -> {}, /* mediaTransferListener= */ null, PlayerId.UNSET)); + // We don't expect this to prepare successfully. + RobolectricUtil.runMainLooperUntil( + () -> { + try { + mediaSource.maybeThrowSourceInfoRefreshError(); + return false; + } catch (IOException e) { + return true; + } + }); + } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java b/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java index c61d7eaba5b..2a401cbbae1 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java @@ -18,6 +18,7 @@ import static java.lang.Math.min; import androidx.annotation.Nullable; +import androidx.media3.common.C; import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Log; @@ -786,40 +787,105 @@ private static void skipH265ScalingList(ParsableNalUnitBitArray bitArray) { } } + /** + * Skips any short term reference picture sets contained in a SPS. + * + *

Note: The st_ref_pic_set parsing in this method is simplified for the case where they're + * contained in a SPS, and would need generalizing for use elsewhere. + */ private static void skipShortTermReferencePictureSets(ParsableNalUnitBitArray bitArray) { int numShortTermRefPicSets = bitArray.readUnsignedExpGolombCodedInt(); - boolean interRefPicSetPredictionFlag = false; - int numNegativePics; - int numPositivePics; - // As this method applies in a SPS, the only element of NumDeltaPocs accessed is the previous - // one, so we just keep track of that rather than storing the whole array. - // RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1) and delta_idx_minus1 is always zero in SPS. - int previousNumDeltaPocs = 0; + // As this method applies in a SPS, each short term reference picture set only accesses data + // from the previous one. This is because RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1), and + // delta_idx_minus1 is always zero in a SPS. Hence we just keep track of variables from the + // previous one as we iterate. + int previousNumNegativePics = C.INDEX_UNSET; + int previousNumPositivePics = C.INDEX_UNSET; + int[] previousDeltaPocS0 = new int[0]; + int[] previousDeltaPocS1 = new int[0]; for (int stRpsIdx = 0; stRpsIdx < numShortTermRefPicSets; stRpsIdx++) { - if (stRpsIdx != 0) { - interRefPicSetPredictionFlag = bitArray.readBit(); - } + int numNegativePics; + int numPositivePics; + int[] deltaPocS0; + int[] deltaPocS1; + + boolean interRefPicSetPredictionFlag = stRpsIdx != 0 && bitArray.readBit(); if (interRefPicSetPredictionFlag) { - bitArray.skipBit(); // delta_rps_sign - bitArray.readUnsignedExpGolombCodedInt(); // abs_delta_rps_minus1 + int previousNumDeltaPocs = previousNumNegativePics + previousNumPositivePics; + + int deltaRpsSign = bitArray.readBit() ? 1 : 0; + int absDeltaRps = bitArray.readUnsignedExpGolombCodedInt() + 1; + int deltaRps = (1 - 2 * deltaRpsSign) * absDeltaRps; + + boolean[] useDeltaFlags = new boolean[previousNumDeltaPocs + 1]; for (int j = 0; j <= previousNumDeltaPocs; j++) { if (!bitArray.readBit()) { // used_by_curr_pic_flag[j] - bitArray.skipBit(); // use_delta_flag[j] + useDeltaFlags[j] = bitArray.readBit(); + } else { + // When use_delta_flag[j] is not present, its value is 1. + useDeltaFlags[j] = true; + } + } + + // Derive numNegativePics, numPositivePics, deltaPocS0 and deltaPocS1 as per Rec. ITU-T + // H.265 v6 (06/2019) Section 7.4.8 + int i = 0; + deltaPocS0 = new int[previousNumDeltaPocs + 1]; + deltaPocS1 = new int[previousNumDeltaPocs + 1]; + for (int j = previousNumPositivePics - 1; j >= 0; j--) { + int dPoc = previousDeltaPocS1[j] + deltaRps; + if (dPoc < 0 && useDeltaFlags[previousNumNegativePics + j]) { + deltaPocS0[i++] = dPoc; + } + } + if (deltaRps < 0 && useDeltaFlags[previousNumDeltaPocs]) { + deltaPocS0[i++] = deltaRps; + } + for (int j = 0; j < previousNumNegativePics; j++) { + int dPoc = previousDeltaPocS0[j] + deltaRps; + if (dPoc < 0 && useDeltaFlags[j]) { + deltaPocS0[i++] = dPoc; + } + } + numNegativePics = i; + deltaPocS0 = Arrays.copyOf(deltaPocS0, numNegativePics); + + i = 0; + for (int j = previousNumNegativePics - 1; j >= 0; j--) { + int dPoc = previousDeltaPocS0[j] + deltaRps; + if (dPoc > 0 && useDeltaFlags[j]) { + deltaPocS1[i++] = dPoc; + } + } + if (deltaRps > 0 && useDeltaFlags[previousNumDeltaPocs]) { + deltaPocS1[i++] = deltaRps; + } + for (int j = 0; j < previousNumPositivePics; j++) { + int dPoc = previousDeltaPocS1[j] + deltaRps; + if (dPoc > 0 && useDeltaFlags[previousNumNegativePics + j]) { + deltaPocS1[i++] = dPoc; } } + numPositivePics = i; + deltaPocS1 = Arrays.copyOf(deltaPocS1, numPositivePics); } else { numNegativePics = bitArray.readUnsignedExpGolombCodedInt(); numPositivePics = bitArray.readUnsignedExpGolombCodedInt(); - previousNumDeltaPocs = numNegativePics + numPositivePics; + deltaPocS0 = new int[numNegativePics]; for (int i = 0; i < numNegativePics; i++) { - bitArray.readUnsignedExpGolombCodedInt(); // delta_poc_s0_minus1[i] + deltaPocS0[i] = bitArray.readUnsignedExpGolombCodedInt() + 1; bitArray.skipBit(); // used_by_curr_pic_s0_flag[i] } + deltaPocS1 = new int[numPositivePics]; for (int i = 0; i < numPositivePics; i++) { - bitArray.readUnsignedExpGolombCodedInt(); // delta_poc_s1_minus1[i] + deltaPocS1[i] = bitArray.readUnsignedExpGolombCodedInt() + 1; bitArray.skipBit(); // used_by_curr_pic_s1_flag[i] } } + previousNumNegativePics = numNegativePics; + previousNumPositivePics = numPositivePics; + previousDeltaPocS0 = deltaPocS0; + previousDeltaPocS1 = deltaPocS1; } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java index 05511fc7e59..4543d32819b 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/AtomParsers.java @@ -45,6 +45,7 @@ import androidx.media3.extractor.metadata.mp4.SmtaMetadataEntry; import com.google.common.base.Function; import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Ints; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; @@ -1303,7 +1304,9 @@ private static void parseVideoSampleEntry( } if (esdsData != null) { - formatBuilder.setAverageBitrate(esdsData.bitrate).setPeakBitrate(esdsData.peakBitrate); + formatBuilder + .setAverageBitrate(Ints.saturatedCast(esdsData.bitrate)) + .setPeakBitrate(Ints.saturatedCast(esdsData.peakBitrate)); } out.format = formatBuilder.build(); @@ -1609,7 +1612,9 @@ private static void parseAudioSampleEntry( .setLanguage(language); if (esdsData != null) { - formatBuilder.setAverageBitrate(esdsData.bitrate).setPeakBitrate(esdsData.peakBitrate); + formatBuilder + .setAverageBitrate(Ints.saturatedCast(esdsData.bitrate)) + .setPeakBitrate(Ints.saturatedCast(esdsData.peakBitrate)); } out.format = formatBuilder.build(); @@ -1659,7 +1664,7 @@ private static EsdsData parseEsdsFromParent(ParsableByteArray parent, int positi parent.skipBytes(2); } if ((flags & 0x40 /* URL_Flag */) != 0) { - parent.skipBytes(parent.readUnsignedShort()); + parent.skipBytes(parent.readUnsignedByte()); } if ((flags & 0x20 /* OCRstreamFlag */) != 0) { parent.skipBytes(2); @@ -1683,8 +1688,8 @@ private static EsdsData parseEsdsFromParent(ParsableByteArray parent, int positi } parent.skipBytes(4); - int peakBitrate = parent.readUnsignedIntToInt(); - int bitrate = parent.readUnsignedIntToInt(); + long peakBitrate = parent.readUnsignedInt(); + long bitrate = parent.readUnsignedInt(); // Start of the DecoderSpecificInfo. parent.skipBytes(1); // DecoderSpecificInfo tag @@ -1943,14 +1948,14 @@ public StsdData(int numberOfEntries) { private static final class EsdsData { private final @NullableType String mimeType; private final byte @NullableType [] initializationData; - private final int bitrate; - private final int peakBitrate; + private final long bitrate; + private final long peakBitrate; public EsdsData( @NullableType String mimeType, byte @NullableType [] initializationData, - int bitrate, - int peakBitrate) { + long bitrate, + long peakBitrate) { this.mimeType = mimeType; this.initializationData = initializationData; this.bitrate = bitrate; diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java index 01d7fe15f97..59dd8543dbb 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java @@ -170,6 +170,32 @@ public void discardToSps() { assertDiscardToSpsMatchesExpected("FF00000001660000000167FF", "0000000167FF"); } + /** Regression test for https://github.com/google/ExoPlayer/issues/10316. */ + @Test + public void parseH265SpsNalUnitPayload_exoghi_10316() { + byte[] spsNalUnitPayload = + new byte[] { + 1, 2, 32, 0, 0, 3, 0, -112, 0, 0, 3, 0, 0, 3, 0, -106, -96, 1, -32, 32, 2, 28, 77, -98, + 87, -110, 66, -111, -123, 22, 74, -86, -53, -101, -98, -68, -28, 9, 119, -21, -103, 120, + -16, 22, -95, 34, 1, 54, -62, 0, 0, 7, -46, 0, 0, -69, -127, -12, 85, -17, 126, 0, -29, + -128, 28, 120, 1, -57, 0, 56, -15 + }; + + NalUnitUtil.H265SpsData spsData = + NalUnitUtil.parseH265SpsNalUnitPayload(spsNalUnitPayload, 0, spsNalUnitPayload.length); + + assertThat(spsData.constraintBytes).isEqualTo(new int[] {144, 0, 0, 0, 0, 0}); + assertThat(spsData.generalLevelIdc).isEqualTo(150); + assertThat(spsData.generalProfileCompatibilityFlags).isEqualTo(4); + assertThat(spsData.generalProfileIdc).isEqualTo(2); + assertThat(spsData.generalProfileSpace).isEqualTo(0); + assertThat(spsData.generalTierFlag).isFalse(); + assertThat(spsData.height).isEqualTo(2160); + assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1); + assertThat(spsData.seqParameterSetId).isEqualTo(0); + assertThat(spsData.width).isEqualTo(3840); + } + private static byte[] buildTestData() { byte[] data = new byte[20]; for (int i = 0; i < data.length; i++) { diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/FragmentedMp4ExtractorTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/FragmentedMp4ExtractorTest.java index 07c663d426f..269ac4291c2 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/FragmentedMp4ExtractorTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/mp4/FragmentedMp4ExtractorTest.java @@ -122,6 +122,15 @@ public void samplePartiallyFragmented() throws Exception { simulationConfig); } + /** https://github.com/google/ExoPlayer/issues/10381 */ + @Test + public void sampleWithLargeBitrates() throws Exception { + ExtractorAsserts.assertBehavior( + getExtractorFactory(ImmutableList.of()), + "media/mp4/sample_fragmented_large_bitrates.mp4", + simulationConfig); + } + private static ExtractorFactory getExtractorFactory(final List closedCaptionFormats) { return () -> new FragmentedMp4Extractor( diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index 992a6fdad0d..0059a9d99af 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -69,6 +69,15 @@ *

  • {@link MediaController#COMMAND_SEEK_TO_NEXT} to seek to the next item. * * + *

    Custom commands

    + * + * Custom actions are sent to the session under the hood. You can receive them by overriding the + * session callback method {@link MediaSession.Callback#onCustomCommand(MediaSession, + * MediaSession.ControllerInfo, SessionCommand, Bundle)}. This is useful because starting with + * Android 13, the System UI notification sends commands directly to the session. So handling the + * custom commands on the session level allows you to handle them at the same callback for all API + * levels. + * *

    Drawables

    * * The drawables used can be overridden by drawables with the same names defined the application. @@ -219,6 +228,14 @@ public final boolean handleCustomCommand(MediaSession session, String action, Bu * customized by defining the index of the command in compact view of up to 3 commands in their * extras with key {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}. * + *

    To make the custom layout and commands work, you need to {@linkplain + * MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom + * commands to the available commands when a controller {@linkplain + * MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the + * session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)} + * need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession, + * MediaSession.ControllerInfo)} also. + * * @param playerCommands The available player commands. * @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of * commands}. diff --git a/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.0.dump b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.0.dump new file mode 100644 index 00000000000..5b9a721cb6f --- /dev/null +++ b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.0.dump @@ -0,0 +1,339 @@ +seekMap: + isSeekable = true + duration = 1067733 + getPosition(0) = [[timeUs=66733, position=1325]] + getPosition(1) = [[timeUs=66733, position=1325]] + getPosition(533866) = [[timeUs=66733, position=1325]] + getPosition(1067733) = [[timeUs=66733, position=1325]] +numberOfTracks = 2 +track 0: + total output bytes = 85933 + sample count = 30 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.64001F + width = 1080 + height = 720 + initializationData: + data = length 29, hash 4746B5D9 + data = length 10, hash 7A0D0F2B + sample 0: + time = 66733 + flags = 1 + data = length 38070, hash B58E1AEE + sample 1: + time = 200200 + flags = 0 + data = length 8340, hash 8AC449FF + sample 2: + time = 133466 + flags = 0 + data = length 1295, hash C0DA5090 + sample 3: + time = 100100 + flags = 0 + data = length 469, hash D6E0A200 + sample 4: + time = 166833 + flags = 0 + data = length 564, hash E5F56C5B + sample 5: + time = 333666 + flags = 0 + data = length 6075, hash 8756E49E + sample 6: + time = 266933 + flags = 0 + data = length 847, hash DCC2B618 + sample 7: + time = 233566 + flags = 0 + data = length 455, hash B9CCE047 + sample 8: + time = 300300 + flags = 0 + data = length 467, hash 69806D94 + sample 9: + time = 467133 + flags = 0 + data = length 4549, hash 3944F501 + sample 10: + time = 400400 + flags = 0 + data = length 1087, hash 491BF106 + sample 11: + time = 367033 + flags = 0 + data = length 380, hash 5FED016A + sample 12: + time = 433766 + flags = 0 + data = length 455, hash 8A0610 + sample 13: + time = 600600 + flags = 0 + data = length 5190, hash B9031D8 + sample 14: + time = 533866 + flags = 0 + data = length 1071, hash 684E7DC8 + sample 15: + time = 500500 + flags = 0 + data = length 653, hash 8494F326 + sample 16: + time = 567233 + flags = 0 + data = length 485, hash 2CCC85F4 + sample 17: + time = 734066 + flags = 0 + data = length 4884, hash D16B6A96 + sample 18: + time = 667333 + flags = 0 + data = length 997, hash 164FF210 + sample 19: + time = 633966 + flags = 0 + data = length 640, hash F664125B + sample 20: + time = 700700 + flags = 0 + data = length 491, hash B5930C7C + sample 21: + time = 867533 + flags = 0 + data = length 2989, hash 92CF4FCF + sample 22: + time = 800800 + flags = 0 + data = length 838, hash 294A3451 + sample 23: + time = 767433 + flags = 0 + data = length 544, hash FCCE2DE6 + sample 24: + time = 834166 + flags = 0 + data = length 329, hash A654FFA1 + sample 25: + time = 1001000 + flags = 0 + data = length 1517, hash 5F7EBF8B + sample 26: + time = 934266 + flags = 0 + data = length 803, hash 7A5C4C1D + sample 27: + time = 900900 + flags = 0 + data = length 415, hash B31BBC3B + sample 28: + time = 967633 + flags = 0 + data = length 415, hash 850DFEA3 + sample 29: + time = 1034366 + flags = 0 + data = length 619, hash AB5E56CA +track 1: + total output bytes = 18257 + sample count = 46 + format 0: + averageBitrate = 2147483647 + peakBitrate = 2147483647 + id = 2 + sampleMimeType = audio/mp4a-latm + codecs = mp4a.40.2 + channelCount = 1 + sampleRate = 44100 + language = und + initializationData: + data = length 5, hash 2B7623A + sample 0: + time = 0 + flags = 1 + data = length 18, hash 96519432 + sample 1: + time = 23219 + flags = 1 + data = length 4, hash EE9DF + sample 2: + time = 46439 + flags = 1 + data = length 4, hash EEDBF + sample 3: + time = 69659 + flags = 1 + data = length 157, hash E2F078F4 + sample 4: + time = 92879 + flags = 1 + data = length 371, hash B9471F94 + sample 5: + time = 116099 + flags = 1 + data = length 373, hash 2AB265CB + sample 6: + time = 139319 + flags = 1 + data = length 402, hash 1295477C + sample 7: + time = 162539 + flags = 1 + data = length 455, hash 2D8146C8 + sample 8: + time = 185759 + flags = 1 + data = length 434, hash F2C5D287 + sample 9: + time = 208979 + flags = 1 + data = length 450, hash 84143FCD + sample 10: + time = 232199 + flags = 1 + data = length 429, hash EF769D50 + sample 11: + time = 255419 + flags = 1 + data = length 450, hash EC3DE692 + sample 12: + time = 278639 + flags = 1 + data = length 447, hash 3E519E13 + sample 13: + time = 301859 + flags = 1 + data = length 457, hash 1E4F23A0 + sample 14: + time = 325079 + flags = 1 + data = length 447, hash A439EA97 + sample 15: + time = 348299 + flags = 1 + data = length 456, hash 1E9034C6 + sample 16: + time = 371519 + flags = 1 + data = length 398, hash 99DB7345 + sample 17: + time = 394739 + flags = 1 + data = length 474, hash 3F05F10A + sample 18: + time = 417959 + flags = 1 + data = length 416, hash C105EE09 + sample 19: + time = 441179 + flags = 1 + data = length 454, hash 5FDBE458 + sample 20: + time = 464399 + flags = 1 + data = length 438, hash 41A93AC3 + sample 21: + time = 487619 + flags = 1 + data = length 443, hash 10FDA652 + sample 22: + time = 510839 + flags = 1 + data = length 412, hash 1F791E25 + sample 23: + time = 534058 + flags = 1 + data = length 482, hash A6D983D + sample 24: + time = 557278 + flags = 1 + data = length 386, hash BED7392F + sample 25: + time = 580498 + flags = 1 + data = length 463, hash 5309F8C9 + sample 26: + time = 603718 + flags = 1 + data = length 394, hash 21C7321F + sample 27: + time = 626938 + flags = 1 + data = length 489, hash 71B4730D + sample 28: + time = 650158 + flags = 1 + data = length 403, hash D9C6DE89 + sample 29: + time = 673378 + flags = 1 + data = length 447, hash 9B14B73B + sample 30: + time = 696598 + flags = 1 + data = length 439, hash 4760D35B + sample 31: + time = 719818 + flags = 1 + data = length 463, hash 1601F88D + sample 32: + time = 743038 + flags = 1 + data = length 423, hash D4AE6773 + sample 33: + time = 766258 + flags = 1 + data = length 497, hash A3C674D3 + sample 34: + time = 789478 + flags = 1 + data = length 419, hash D3734A1F + sample 35: + time = 812698 + flags = 1 + data = length 474, hash DFB41F9 + sample 36: + time = 835918 + flags = 1 + data = length 413, hash 53E7CB9F + sample 37: + time = 859138 + flags = 1 + data = length 445, hash D15B0E39 + sample 38: + time = 882358 + flags = 1 + data = length 453, hash 77ED81E4 + sample 39: + time = 905578 + flags = 1 + data = length 545, hash 3321AEB9 + sample 40: + time = 928798 + flags = 1 + data = length 317, hash F557D0E + sample 41: + time = 952018 + flags = 1 + data = length 537, hash ED58CF7B + sample 42: + time = 975238 + flags = 1 + data = length 458, hash 51CDAA10 + sample 43: + time = 998458 + flags = 1 + data = length 465, hash CBA1EFD7 + sample 44: + time = 1021678 + flags = 1 + data = length 446, hash D6735B8A + sample 45: + time = 1044897 + flags = 1 + data = length 10, hash A453EEBE +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.1.dump b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.1.dump new file mode 100644 index 00000000000..53cb776780d --- /dev/null +++ b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.1.dump @@ -0,0 +1,279 @@ +seekMap: + isSeekable = true + duration = 1067733 + getPosition(0) = [[timeUs=66733, position=1325]] + getPosition(1) = [[timeUs=66733, position=1325]] + getPosition(533866) = [[timeUs=66733, position=1325]] + getPosition(1067733) = [[timeUs=66733, position=1325]] +numberOfTracks = 2 +track 0: + total output bytes = 85933 + sample count = 30 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.64001F + width = 1080 + height = 720 + initializationData: + data = length 29, hash 4746B5D9 + data = length 10, hash 7A0D0F2B + sample 0: + time = 66733 + flags = 1 + data = length 38070, hash B58E1AEE + sample 1: + time = 200200 + flags = 0 + data = length 8340, hash 8AC449FF + sample 2: + time = 133466 + flags = 0 + data = length 1295, hash C0DA5090 + sample 3: + time = 100100 + flags = 0 + data = length 469, hash D6E0A200 + sample 4: + time = 166833 + flags = 0 + data = length 564, hash E5F56C5B + sample 5: + time = 333666 + flags = 0 + data = length 6075, hash 8756E49E + sample 6: + time = 266933 + flags = 0 + data = length 847, hash DCC2B618 + sample 7: + time = 233566 + flags = 0 + data = length 455, hash B9CCE047 + sample 8: + time = 300300 + flags = 0 + data = length 467, hash 69806D94 + sample 9: + time = 467133 + flags = 0 + data = length 4549, hash 3944F501 + sample 10: + time = 400400 + flags = 0 + data = length 1087, hash 491BF106 + sample 11: + time = 367033 + flags = 0 + data = length 380, hash 5FED016A + sample 12: + time = 433766 + flags = 0 + data = length 455, hash 8A0610 + sample 13: + time = 600600 + flags = 0 + data = length 5190, hash B9031D8 + sample 14: + time = 533866 + flags = 0 + data = length 1071, hash 684E7DC8 + sample 15: + time = 500500 + flags = 0 + data = length 653, hash 8494F326 + sample 16: + time = 567233 + flags = 0 + data = length 485, hash 2CCC85F4 + sample 17: + time = 734066 + flags = 0 + data = length 4884, hash D16B6A96 + sample 18: + time = 667333 + flags = 0 + data = length 997, hash 164FF210 + sample 19: + time = 633966 + flags = 0 + data = length 640, hash F664125B + sample 20: + time = 700700 + flags = 0 + data = length 491, hash B5930C7C + sample 21: + time = 867533 + flags = 0 + data = length 2989, hash 92CF4FCF + sample 22: + time = 800800 + flags = 0 + data = length 838, hash 294A3451 + sample 23: + time = 767433 + flags = 0 + data = length 544, hash FCCE2DE6 + sample 24: + time = 834166 + flags = 0 + data = length 329, hash A654FFA1 + sample 25: + time = 1001000 + flags = 0 + data = length 1517, hash 5F7EBF8B + sample 26: + time = 934266 + flags = 0 + data = length 803, hash 7A5C4C1D + sample 27: + time = 900900 + flags = 0 + data = length 415, hash B31BBC3B + sample 28: + time = 967633 + flags = 0 + data = length 415, hash 850DFEA3 + sample 29: + time = 1034366 + flags = 0 + data = length 619, hash AB5E56CA +track 1: + total output bytes = 13359 + sample count = 31 + format 0: + averageBitrate = 2147483647 + peakBitrate = 2147483647 + id = 2 + sampleMimeType = audio/mp4a-latm + codecs = mp4a.40.2 + channelCount = 1 + sampleRate = 44100 + language = und + initializationData: + data = length 5, hash 2B7623A + sample 0: + time = 348299 + flags = 1 + data = length 456, hash 1E9034C6 + sample 1: + time = 371519 + flags = 1 + data = length 398, hash 99DB7345 + sample 2: + time = 394739 + flags = 1 + data = length 474, hash 3F05F10A + sample 3: + time = 417959 + flags = 1 + data = length 416, hash C105EE09 + sample 4: + time = 441179 + flags = 1 + data = length 454, hash 5FDBE458 + sample 5: + time = 464399 + flags = 1 + data = length 438, hash 41A93AC3 + sample 6: + time = 487619 + flags = 1 + data = length 443, hash 10FDA652 + sample 7: + time = 510839 + flags = 1 + data = length 412, hash 1F791E25 + sample 8: + time = 534058 + flags = 1 + data = length 482, hash A6D983D + sample 9: + time = 557278 + flags = 1 + data = length 386, hash BED7392F + sample 10: + time = 580498 + flags = 1 + data = length 463, hash 5309F8C9 + sample 11: + time = 603718 + flags = 1 + data = length 394, hash 21C7321F + sample 12: + time = 626938 + flags = 1 + data = length 489, hash 71B4730D + sample 13: + time = 650158 + flags = 1 + data = length 403, hash D9C6DE89 + sample 14: + time = 673378 + flags = 1 + data = length 447, hash 9B14B73B + sample 15: + time = 696598 + flags = 1 + data = length 439, hash 4760D35B + sample 16: + time = 719818 + flags = 1 + data = length 463, hash 1601F88D + sample 17: + time = 743038 + flags = 1 + data = length 423, hash D4AE6773 + sample 18: + time = 766258 + flags = 1 + data = length 497, hash A3C674D3 + sample 19: + time = 789478 + flags = 1 + data = length 419, hash D3734A1F + sample 20: + time = 812698 + flags = 1 + data = length 474, hash DFB41F9 + sample 21: + time = 835918 + flags = 1 + data = length 413, hash 53E7CB9F + sample 22: + time = 859138 + flags = 1 + data = length 445, hash D15B0E39 + sample 23: + time = 882358 + flags = 1 + data = length 453, hash 77ED81E4 + sample 24: + time = 905578 + flags = 1 + data = length 545, hash 3321AEB9 + sample 25: + time = 928798 + flags = 1 + data = length 317, hash F557D0E + sample 26: + time = 952018 + flags = 1 + data = length 537, hash ED58CF7B + sample 27: + time = 975238 + flags = 1 + data = length 458, hash 51CDAA10 + sample 28: + time = 998458 + flags = 1 + data = length 465, hash CBA1EFD7 + sample 29: + time = 1021678 + flags = 1 + data = length 446, hash D6735B8A + sample 30: + time = 1044897 + flags = 1 + data = length 10, hash A453EEBE +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.2.dump b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.2.dump new file mode 100644 index 00000000000..ecb83ddeeaa --- /dev/null +++ b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.2.dump @@ -0,0 +1,219 @@ +seekMap: + isSeekable = true + duration = 1067733 + getPosition(0) = [[timeUs=66733, position=1325]] + getPosition(1) = [[timeUs=66733, position=1325]] + getPosition(533866) = [[timeUs=66733, position=1325]] + getPosition(1067733) = [[timeUs=66733, position=1325]] +numberOfTracks = 2 +track 0: + total output bytes = 85933 + sample count = 30 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.64001F + width = 1080 + height = 720 + initializationData: + data = length 29, hash 4746B5D9 + data = length 10, hash 7A0D0F2B + sample 0: + time = 66733 + flags = 1 + data = length 38070, hash B58E1AEE + sample 1: + time = 200200 + flags = 0 + data = length 8340, hash 8AC449FF + sample 2: + time = 133466 + flags = 0 + data = length 1295, hash C0DA5090 + sample 3: + time = 100100 + flags = 0 + data = length 469, hash D6E0A200 + sample 4: + time = 166833 + flags = 0 + data = length 564, hash E5F56C5B + sample 5: + time = 333666 + flags = 0 + data = length 6075, hash 8756E49E + sample 6: + time = 266933 + flags = 0 + data = length 847, hash DCC2B618 + sample 7: + time = 233566 + flags = 0 + data = length 455, hash B9CCE047 + sample 8: + time = 300300 + flags = 0 + data = length 467, hash 69806D94 + sample 9: + time = 467133 + flags = 0 + data = length 4549, hash 3944F501 + sample 10: + time = 400400 + flags = 0 + data = length 1087, hash 491BF106 + sample 11: + time = 367033 + flags = 0 + data = length 380, hash 5FED016A + sample 12: + time = 433766 + flags = 0 + data = length 455, hash 8A0610 + sample 13: + time = 600600 + flags = 0 + data = length 5190, hash B9031D8 + sample 14: + time = 533866 + flags = 0 + data = length 1071, hash 684E7DC8 + sample 15: + time = 500500 + flags = 0 + data = length 653, hash 8494F326 + sample 16: + time = 567233 + flags = 0 + data = length 485, hash 2CCC85F4 + sample 17: + time = 734066 + flags = 0 + data = length 4884, hash D16B6A96 + sample 18: + time = 667333 + flags = 0 + data = length 997, hash 164FF210 + sample 19: + time = 633966 + flags = 0 + data = length 640, hash F664125B + sample 20: + time = 700700 + flags = 0 + data = length 491, hash B5930C7C + sample 21: + time = 867533 + flags = 0 + data = length 2989, hash 92CF4FCF + sample 22: + time = 800800 + flags = 0 + data = length 838, hash 294A3451 + sample 23: + time = 767433 + flags = 0 + data = length 544, hash FCCE2DE6 + sample 24: + time = 834166 + flags = 0 + data = length 329, hash A654FFA1 + sample 25: + time = 1001000 + flags = 0 + data = length 1517, hash 5F7EBF8B + sample 26: + time = 934266 + flags = 0 + data = length 803, hash 7A5C4C1D + sample 27: + time = 900900 + flags = 0 + data = length 415, hash B31BBC3B + sample 28: + time = 967633 + flags = 0 + data = length 415, hash 850DFEA3 + sample 29: + time = 1034366 + flags = 0 + data = length 619, hash AB5E56CA +track 1: + total output bytes = 6804 + sample count = 16 + format 0: + averageBitrate = 2147483647 + peakBitrate = 2147483647 + id = 2 + sampleMimeType = audio/mp4a-latm + codecs = mp4a.40.2 + channelCount = 1 + sampleRate = 44100 + language = und + initializationData: + data = length 5, hash 2B7623A + sample 0: + time = 696598 + flags = 1 + data = length 439, hash 4760D35B + sample 1: + time = 719818 + flags = 1 + data = length 463, hash 1601F88D + sample 2: + time = 743038 + flags = 1 + data = length 423, hash D4AE6773 + sample 3: + time = 766258 + flags = 1 + data = length 497, hash A3C674D3 + sample 4: + time = 789478 + flags = 1 + data = length 419, hash D3734A1F + sample 5: + time = 812698 + flags = 1 + data = length 474, hash DFB41F9 + sample 6: + time = 835918 + flags = 1 + data = length 413, hash 53E7CB9F + sample 7: + time = 859138 + flags = 1 + data = length 445, hash D15B0E39 + sample 8: + time = 882358 + flags = 1 + data = length 453, hash 77ED81E4 + sample 9: + time = 905578 + flags = 1 + data = length 545, hash 3321AEB9 + sample 10: + time = 928798 + flags = 1 + data = length 317, hash F557D0E + sample 11: + time = 952018 + flags = 1 + data = length 537, hash ED58CF7B + sample 12: + time = 975238 + flags = 1 + data = length 458, hash 51CDAA10 + sample 13: + time = 998458 + flags = 1 + data = length 465, hash CBA1EFD7 + sample 14: + time = 1021678 + flags = 1 + data = length 446, hash D6735B8A + sample 15: + time = 1044897 + flags = 1 + data = length 10, hash A453EEBE +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.3.dump b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.3.dump new file mode 100644 index 00000000000..c0498099406 --- /dev/null +++ b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.3.dump @@ -0,0 +1,159 @@ +seekMap: + isSeekable = true + duration = 1067733 + getPosition(0) = [[timeUs=66733, position=1325]] + getPosition(1) = [[timeUs=66733, position=1325]] + getPosition(533866) = [[timeUs=66733, position=1325]] + getPosition(1067733) = [[timeUs=66733, position=1325]] +numberOfTracks = 2 +track 0: + total output bytes = 85933 + sample count = 30 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.64001F + width = 1080 + height = 720 + initializationData: + data = length 29, hash 4746B5D9 + data = length 10, hash 7A0D0F2B + sample 0: + time = 66733 + flags = 1 + data = length 38070, hash B58E1AEE + sample 1: + time = 200200 + flags = 0 + data = length 8340, hash 8AC449FF + sample 2: + time = 133466 + flags = 0 + data = length 1295, hash C0DA5090 + sample 3: + time = 100100 + flags = 0 + data = length 469, hash D6E0A200 + sample 4: + time = 166833 + flags = 0 + data = length 564, hash E5F56C5B + sample 5: + time = 333666 + flags = 0 + data = length 6075, hash 8756E49E + sample 6: + time = 266933 + flags = 0 + data = length 847, hash DCC2B618 + sample 7: + time = 233566 + flags = 0 + data = length 455, hash B9CCE047 + sample 8: + time = 300300 + flags = 0 + data = length 467, hash 69806D94 + sample 9: + time = 467133 + flags = 0 + data = length 4549, hash 3944F501 + sample 10: + time = 400400 + flags = 0 + data = length 1087, hash 491BF106 + sample 11: + time = 367033 + flags = 0 + data = length 380, hash 5FED016A + sample 12: + time = 433766 + flags = 0 + data = length 455, hash 8A0610 + sample 13: + time = 600600 + flags = 0 + data = length 5190, hash B9031D8 + sample 14: + time = 533866 + flags = 0 + data = length 1071, hash 684E7DC8 + sample 15: + time = 500500 + flags = 0 + data = length 653, hash 8494F326 + sample 16: + time = 567233 + flags = 0 + data = length 485, hash 2CCC85F4 + sample 17: + time = 734066 + flags = 0 + data = length 4884, hash D16B6A96 + sample 18: + time = 667333 + flags = 0 + data = length 997, hash 164FF210 + sample 19: + time = 633966 + flags = 0 + data = length 640, hash F664125B + sample 20: + time = 700700 + flags = 0 + data = length 491, hash B5930C7C + sample 21: + time = 867533 + flags = 0 + data = length 2989, hash 92CF4FCF + sample 22: + time = 800800 + flags = 0 + data = length 838, hash 294A3451 + sample 23: + time = 767433 + flags = 0 + data = length 544, hash FCCE2DE6 + sample 24: + time = 834166 + flags = 0 + data = length 329, hash A654FFA1 + sample 25: + time = 1001000 + flags = 0 + data = length 1517, hash 5F7EBF8B + sample 26: + time = 934266 + flags = 0 + data = length 803, hash 7A5C4C1D + sample 27: + time = 900900 + flags = 0 + data = length 415, hash B31BBC3B + sample 28: + time = 967633 + flags = 0 + data = length 415, hash 850DFEA3 + sample 29: + time = 1034366 + flags = 0 + data = length 619, hash AB5E56CA +track 1: + total output bytes = 10 + sample count = 1 + format 0: + averageBitrate = 2147483647 + peakBitrate = 2147483647 + id = 2 + sampleMimeType = audio/mp4a-latm + codecs = mp4a.40.2 + channelCount = 1 + sampleRate = 44100 + language = und + initializationData: + data = length 5, hash 2B7623A + sample 0: + time = 1044897 + flags = 1 + data = length 10, hash A453EEBE +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.unknown_length.dump b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.unknown_length.dump new file mode 100644 index 00000000000..5b9a721cb6f --- /dev/null +++ b/libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.unknown_length.dump @@ -0,0 +1,339 @@ +seekMap: + isSeekable = true + duration = 1067733 + getPosition(0) = [[timeUs=66733, position=1325]] + getPosition(1) = [[timeUs=66733, position=1325]] + getPosition(533866) = [[timeUs=66733, position=1325]] + getPosition(1067733) = [[timeUs=66733, position=1325]] +numberOfTracks = 2 +track 0: + total output bytes = 85933 + sample count = 30 + format 0: + id = 1 + sampleMimeType = video/avc + codecs = avc1.64001F + width = 1080 + height = 720 + initializationData: + data = length 29, hash 4746B5D9 + data = length 10, hash 7A0D0F2B + sample 0: + time = 66733 + flags = 1 + data = length 38070, hash B58E1AEE + sample 1: + time = 200200 + flags = 0 + data = length 8340, hash 8AC449FF + sample 2: + time = 133466 + flags = 0 + data = length 1295, hash C0DA5090 + sample 3: + time = 100100 + flags = 0 + data = length 469, hash D6E0A200 + sample 4: + time = 166833 + flags = 0 + data = length 564, hash E5F56C5B + sample 5: + time = 333666 + flags = 0 + data = length 6075, hash 8756E49E + sample 6: + time = 266933 + flags = 0 + data = length 847, hash DCC2B618 + sample 7: + time = 233566 + flags = 0 + data = length 455, hash B9CCE047 + sample 8: + time = 300300 + flags = 0 + data = length 467, hash 69806D94 + sample 9: + time = 467133 + flags = 0 + data = length 4549, hash 3944F501 + sample 10: + time = 400400 + flags = 0 + data = length 1087, hash 491BF106 + sample 11: + time = 367033 + flags = 0 + data = length 380, hash 5FED016A + sample 12: + time = 433766 + flags = 0 + data = length 455, hash 8A0610 + sample 13: + time = 600600 + flags = 0 + data = length 5190, hash B9031D8 + sample 14: + time = 533866 + flags = 0 + data = length 1071, hash 684E7DC8 + sample 15: + time = 500500 + flags = 0 + data = length 653, hash 8494F326 + sample 16: + time = 567233 + flags = 0 + data = length 485, hash 2CCC85F4 + sample 17: + time = 734066 + flags = 0 + data = length 4884, hash D16B6A96 + sample 18: + time = 667333 + flags = 0 + data = length 997, hash 164FF210 + sample 19: + time = 633966 + flags = 0 + data = length 640, hash F664125B + sample 20: + time = 700700 + flags = 0 + data = length 491, hash B5930C7C + sample 21: + time = 867533 + flags = 0 + data = length 2989, hash 92CF4FCF + sample 22: + time = 800800 + flags = 0 + data = length 838, hash 294A3451 + sample 23: + time = 767433 + flags = 0 + data = length 544, hash FCCE2DE6 + sample 24: + time = 834166 + flags = 0 + data = length 329, hash A654FFA1 + sample 25: + time = 1001000 + flags = 0 + data = length 1517, hash 5F7EBF8B + sample 26: + time = 934266 + flags = 0 + data = length 803, hash 7A5C4C1D + sample 27: + time = 900900 + flags = 0 + data = length 415, hash B31BBC3B + sample 28: + time = 967633 + flags = 0 + data = length 415, hash 850DFEA3 + sample 29: + time = 1034366 + flags = 0 + data = length 619, hash AB5E56CA +track 1: + total output bytes = 18257 + sample count = 46 + format 0: + averageBitrate = 2147483647 + peakBitrate = 2147483647 + id = 2 + sampleMimeType = audio/mp4a-latm + codecs = mp4a.40.2 + channelCount = 1 + sampleRate = 44100 + language = und + initializationData: + data = length 5, hash 2B7623A + sample 0: + time = 0 + flags = 1 + data = length 18, hash 96519432 + sample 1: + time = 23219 + flags = 1 + data = length 4, hash EE9DF + sample 2: + time = 46439 + flags = 1 + data = length 4, hash EEDBF + sample 3: + time = 69659 + flags = 1 + data = length 157, hash E2F078F4 + sample 4: + time = 92879 + flags = 1 + data = length 371, hash B9471F94 + sample 5: + time = 116099 + flags = 1 + data = length 373, hash 2AB265CB + sample 6: + time = 139319 + flags = 1 + data = length 402, hash 1295477C + sample 7: + time = 162539 + flags = 1 + data = length 455, hash 2D8146C8 + sample 8: + time = 185759 + flags = 1 + data = length 434, hash F2C5D287 + sample 9: + time = 208979 + flags = 1 + data = length 450, hash 84143FCD + sample 10: + time = 232199 + flags = 1 + data = length 429, hash EF769D50 + sample 11: + time = 255419 + flags = 1 + data = length 450, hash EC3DE692 + sample 12: + time = 278639 + flags = 1 + data = length 447, hash 3E519E13 + sample 13: + time = 301859 + flags = 1 + data = length 457, hash 1E4F23A0 + sample 14: + time = 325079 + flags = 1 + data = length 447, hash A439EA97 + sample 15: + time = 348299 + flags = 1 + data = length 456, hash 1E9034C6 + sample 16: + time = 371519 + flags = 1 + data = length 398, hash 99DB7345 + sample 17: + time = 394739 + flags = 1 + data = length 474, hash 3F05F10A + sample 18: + time = 417959 + flags = 1 + data = length 416, hash C105EE09 + sample 19: + time = 441179 + flags = 1 + data = length 454, hash 5FDBE458 + sample 20: + time = 464399 + flags = 1 + data = length 438, hash 41A93AC3 + sample 21: + time = 487619 + flags = 1 + data = length 443, hash 10FDA652 + sample 22: + time = 510839 + flags = 1 + data = length 412, hash 1F791E25 + sample 23: + time = 534058 + flags = 1 + data = length 482, hash A6D983D + sample 24: + time = 557278 + flags = 1 + data = length 386, hash BED7392F + sample 25: + time = 580498 + flags = 1 + data = length 463, hash 5309F8C9 + sample 26: + time = 603718 + flags = 1 + data = length 394, hash 21C7321F + sample 27: + time = 626938 + flags = 1 + data = length 489, hash 71B4730D + sample 28: + time = 650158 + flags = 1 + data = length 403, hash D9C6DE89 + sample 29: + time = 673378 + flags = 1 + data = length 447, hash 9B14B73B + sample 30: + time = 696598 + flags = 1 + data = length 439, hash 4760D35B + sample 31: + time = 719818 + flags = 1 + data = length 463, hash 1601F88D + sample 32: + time = 743038 + flags = 1 + data = length 423, hash D4AE6773 + sample 33: + time = 766258 + flags = 1 + data = length 497, hash A3C674D3 + sample 34: + time = 789478 + flags = 1 + data = length 419, hash D3734A1F + sample 35: + time = 812698 + flags = 1 + data = length 474, hash DFB41F9 + sample 36: + time = 835918 + flags = 1 + data = length 413, hash 53E7CB9F + sample 37: + time = 859138 + flags = 1 + data = length 445, hash D15B0E39 + sample 38: + time = 882358 + flags = 1 + data = length 453, hash 77ED81E4 + sample 39: + time = 905578 + flags = 1 + data = length 545, hash 3321AEB9 + sample 40: + time = 928798 + flags = 1 + data = length 317, hash F557D0E + sample 41: + time = 952018 + flags = 1 + data = length 537, hash ED58CF7B + sample 42: + time = 975238 + flags = 1 + data = length 458, hash 51CDAA10 + sample 43: + time = 998458 + flags = 1 + data = length 465, hash CBA1EFD7 + sample 44: + time = 1021678 + flags = 1 + data = length 446, hash D6735B8A + sample 45: + time = 1044897 + flags = 1 + data = length 10, hash A453EEBE +tracksEnded = true diff --git a/libraries/test_data/src/test/assets/media/mp4/sample_fragmented_large_bitrates.mp4 b/libraries/test_data/src/test/assets/media/mp4/sample_fragmented_large_bitrates.mp4 new file mode 100644 index 00000000000..39fd4c18cf2 Binary files /dev/null and b/libraries/test_data/src/test/assets/media/mp4/sample_fragmented_large_bitrates.mp4 differ diff --git a/libraries/test_data/src/test/assets/media/mpd/sample_mpd_clear_key_license_url b/libraries/test_data/src/test/assets/media/mpd/sample_mpd_clear_key_license_url new file mode 100644 index 00000000000..ed362b729a4 --- /dev/null +++ b/libraries/test_data/src/test/assets/media/mpd/sample_mpd_clear_key_license_url @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + https://testserver1.test/AcquireLicense + + + + + + https://testserver2.test/AcquireLicense + + + + + + diff --git a/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_fragmented_large_bitrates.mp4.dump b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_fragmented_large_bitrates.mp4.dump new file mode 100644 index 00000000000..b8f6657598e --- /dev/null +++ b/libraries/test_data/src/test/assets/playbackdumps/mp4/sample_fragmented_large_bitrates.mp4.dump @@ -0,0 +1,82 @@ +MediaCodecAdapter (exotest.audio.aac): + buffers.length = 47 + buffers[0] = length 18, hash 96519432 + buffers[1] = length 4, hash EE9DF + buffers[2] = length 4, hash EEDBF + buffers[3] = length 157, hash E2F078F4 + buffers[4] = length 371, hash B9471F94 + buffers[5] = length 373, hash 2AB265CB + buffers[6] = length 402, hash 1295477C + buffers[7] = length 455, hash 2D8146C8 + buffers[8] = length 434, hash F2C5D287 + buffers[9] = length 450, hash 84143FCD + buffers[10] = length 429, hash EF769D50 + buffers[11] = length 450, hash EC3DE692 + buffers[12] = length 447, hash 3E519E13 + buffers[13] = length 457, hash 1E4F23A0 + buffers[14] = length 447, hash A439EA97 + buffers[15] = length 456, hash 1E9034C6 + buffers[16] = length 398, hash 99DB7345 + buffers[17] = length 474, hash 3F05F10A + buffers[18] = length 416, hash C105EE09 + buffers[19] = length 454, hash 5FDBE458 + buffers[20] = length 438, hash 41A93AC3 + buffers[21] = length 443, hash 10FDA652 + buffers[22] = length 412, hash 1F791E25 + buffers[23] = length 482, hash A6D983D + buffers[24] = length 386, hash BED7392F + buffers[25] = length 463, hash 5309F8C9 + buffers[26] = length 394, hash 21C7321F + buffers[27] = length 489, hash 71B4730D + buffers[28] = length 403, hash D9C6DE89 + buffers[29] = length 447, hash 9B14B73B + buffers[30] = length 439, hash 4760D35B + buffers[31] = length 463, hash 1601F88D + buffers[32] = length 423, hash D4AE6773 + buffers[33] = length 497, hash A3C674D3 + buffers[34] = length 419, hash D3734A1F + buffers[35] = length 474, hash DFB41F9 + buffers[36] = length 413, hash 53E7CB9F + buffers[37] = length 445, hash D15B0E39 + buffers[38] = length 453, hash 77ED81E4 + buffers[39] = length 545, hash 3321AEB9 + buffers[40] = length 317, hash F557D0E + buffers[41] = length 537, hash ED58CF7B + buffers[42] = length 458, hash 51CDAA10 + buffers[43] = length 465, hash CBA1EFD7 + buffers[44] = length 446, hash D6735B8A + buffers[45] = length 10, hash A453EEBE + buffers[46] = length 0, hash 1 +MediaCodecAdapter (exotest.video.avc): + buffers.length = 31 + buffers[0] = length 38070, hash B58E1AEE + buffers[1] = length 8340, hash 8AC449FF + buffers[2] = length 1295, hash C0DA5090 + buffers[3] = length 469, hash D6E0A200 + buffers[4] = length 564, hash E5F56C5B + buffers[5] = length 6075, hash 8756E49E + buffers[6] = length 847, hash DCC2B618 + buffers[7] = length 455, hash B9CCE047 + buffers[8] = length 467, hash 69806D94 + buffers[9] = length 4549, hash 3944F501 + buffers[10] = length 1087, hash 491BF106 + buffers[11] = length 380, hash 5FED016A + buffers[12] = length 455, hash 8A0610 + buffers[13] = length 5190, hash B9031D8 + buffers[14] = length 1071, hash 684E7DC8 + buffers[15] = length 653, hash 8494F326 + buffers[16] = length 485, hash 2CCC85F4 + buffers[17] = length 4884, hash D16B6A96 + buffers[18] = length 997, hash 164FF210 + buffers[19] = length 640, hash F664125B + buffers[20] = length 491, hash B5930C7C + buffers[21] = length 2989, hash 92CF4FCF + buffers[22] = length 838, hash 294A3451 + buffers[23] = length 544, hash FCCE2DE6 + buffers[24] = length 329, hash A654FFA1 + buffers[25] = length 1517, hash 5F7EBF8B + buffers[26] = length 803, hash 7A5C4C1D + buffers[27] = length 415, hash B31BBC3B + buffers[28] = length 415, hash 850DFEA3 + buffers[29] = length 619, hash AB5E56CA + buffers[30] = length 0, hash 1 diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/Action.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/Action.java index f025836ba24..cf5f420c6a1 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/Action.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/Action.java @@ -15,6 +15,8 @@ */ package androidx.media3.test.utils; +import static androidx.media3.test.utils.TestUtil.timelinesAreSame; + import android.os.Looper; import android.view.Surface; import androidx.annotation.Nullable; @@ -765,7 +767,7 @@ public WaitForTimelineChanged( @Nullable Timeline expectedTimeline, @Player.TimelineChangeReason int expectedReason) { super(tag, "WaitForTimelineChanged"); - this.expectedTimeline = expectedTimeline != null ? new NoUidTimeline(expectedTimeline) : null; + this.expectedTimeline = expectedTimeline; this.ignoreExpectedReason = false; this.expectedReason = expectedReason; } @@ -797,7 +799,7 @@ protected void doActionAndScheduleNextImpl( @Override public void onTimelineChanged( Timeline timeline, @Player.TimelineChangeReason int reason) { - if ((expectedTimeline == null || new NoUidTimeline(timeline).equals(expectedTimeline)) + if ((expectedTimeline == null || timelinesAreSame(timeline, expectedTimeline)) && (ignoreExpectedReason || expectedReason == reason)) { player.removeListener(this); nextAction.schedule(player, trackSelector, surface, handler); @@ -805,8 +807,8 @@ public void onTimelineChanged( } }; player.addListener(listener); - Timeline currentTimeline = new NoUidTimeline(player.getCurrentTimeline()); - if (currentTimeline.equals(expectedTimeline)) { + if (expectedTimeline != null + && timelinesAreSame(player.getCurrentTimeline(), expectedTimeline)) { player.removeListener(listener); nextAction.schedule(player, trackSelector, surface, handler); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/ExoPlayerTestRunner.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/ExoPlayerTestRunner.java index 3bf7bc3866c..0cb7b4d9353 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/ExoPlayerTestRunner.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/ExoPlayerTestRunner.java @@ -43,6 +43,7 @@ import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; import androidx.media3.exoplayer.upstream.BandwidthMeter; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -536,11 +537,8 @@ public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs) * @param timelines A list of expected {@link Timeline}s. */ public void assertTimelinesSame(Timeline... timelines) { - assertThat(this.timelines).hasSize(timelines.length); - for (int i = 0; i < timelines.length; i++) { - assertThat(new NoUidTimeline(timelines[i])) - .isEqualTo(new NoUidTimeline(this.timelines.get(i))); - } + TestUtil.assertTimelinesSame( + ImmutableList.copyOf(this.timelines), ImmutableList.copyOf(timelines)); } /** diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java index 75e3d26e7b9..9aad788a7a7 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/FakeTimeline.java @@ -29,6 +29,7 @@ import androidx.media3.common.util.Assertions; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.source.ShuffleOrder; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.util.ArrayList; @@ -275,7 +276,7 @@ public TimelineWindowDefinition( private final TimelineWindowDefinition[] windowDefinitions; private final Object[] manifests; private final int[] periodOffsets; - private final FakeShuffleOrder fakeShuffleOrder; + private final ShuffleOrder shuffleOrder; /** * Returns an ad playback state with the specified number of ads in each of the specified ad @@ -395,6 +396,19 @@ public FakeTimeline(TimelineWindowDefinition... windowDefinitions) { * @param windowDefinitions A list of {@link TimelineWindowDefinition}s. */ public FakeTimeline(Object[] manifests, TimelineWindowDefinition... windowDefinitions) { + this(manifests, new FakeShuffleOrder(windowDefinitions.length), windowDefinitions); + } + + /** + * Creates a fake timeline with the given window definitions and {@link + * androidx.media3.exoplayer.source.ShuffleOrder}. + * + * @param windowDefinitions A list of {@link TimelineWindowDefinition}s. + */ + public FakeTimeline( + Object[] manifests, + ShuffleOrder shuffleOrder, + TimelineWindowDefinition... windowDefinitions) { this.manifests = new Object[windowDefinitions.length]; System.arraycopy(manifests, 0, this.manifests, 0, min(this.manifests.length, manifests.length)); this.windowDefinitions = windowDefinitions; @@ -403,7 +417,7 @@ public FakeTimeline(Object[] manifests, TimelineWindowDefinition... windowDefini for (int i = 0; i < windowDefinitions.length; i++) { periodOffsets[i + 1] = periodOffsets[i] + windowDefinitions[i].periodCount; } - fakeShuffleOrder = new FakeShuffleOrder(windowDefinitions.length); + this.shuffleOrder = shuffleOrder; } @Override @@ -422,7 +436,7 @@ public int getNextWindowIndex( ? getFirstWindowIndex(shuffleModeEnabled) : C.INDEX_UNSET; } - return shuffleModeEnabled ? fakeShuffleOrder.getNextIndex(windowIndex) : windowIndex + 1; + return shuffleModeEnabled ? shuffleOrder.getNextIndex(windowIndex) : windowIndex + 1; } @Override @@ -436,20 +450,20 @@ public int getPreviousWindowIndex( ? getLastWindowIndex(shuffleModeEnabled) : C.INDEX_UNSET; } - return shuffleModeEnabled ? fakeShuffleOrder.getPreviousIndex(windowIndex) : windowIndex - 1; + return shuffleModeEnabled ? shuffleOrder.getPreviousIndex(windowIndex) : windowIndex - 1; } @Override public int getLastWindowIndex(boolean shuffleModeEnabled) { return shuffleModeEnabled - ? fakeShuffleOrder.getLastIndex() + ? shuffleOrder.getLastIndex() : super.getLastWindowIndex(/* shuffleModeEnabled= */ false); } @Override public int getFirstWindowIndex(boolean shuffleModeEnabled) { return shuffleModeEnabled - ? fakeShuffleOrder.getFirstIndex() + ? shuffleOrder.getFirstIndex() : super.getFirstWindowIndex(/* shuffleModeEnabled= */ false); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/NoUidTimeline.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/NoUidTimeline.java deleted file mode 100644 index 5cd0adad0c4..00000000000 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/NoUidTimeline.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2019 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 androidx.media3.test.utils; - -import androidx.media3.common.Timeline; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.exoplayer.source.ForwardingTimeline; - -/** - * A timeline which wraps another timeline and overrides all window and period uids to 0. This is - * useful for testing timeline equality without taking uids into account. - */ -@UnstableApi -public class NoUidTimeline extends ForwardingTimeline { - - /** - * Creates an instance. - * - * @param timeline The underlying timeline. - */ - public NoUidTimeline(Timeline timeline) { - super(timeline); - } - - @Override - public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { - timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); - window.uid = 0; - return window; - } - - @Override - public Period getPeriod(int periodIndex, Period period, boolean setIds) { - timeline.getPeriod(periodIndex, period, setIds); - period.uid = 0; - return period; - } -} diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java index 85569df265e..97c7d3cf4a2 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java @@ -70,6 +70,7 @@ public void removeListener(Listener listener) { } @Override + @Nullable public PlaybackException getPlayerError() { throw new UnsupportedOperationException(); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java index a59377c27dc..27872827fa5 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java @@ -44,6 +44,7 @@ import androidx.media3.extractor.metadata.MetadataInputBuffer; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Bytes; +import com.google.common.truth.Correspondence; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -199,19 +200,33 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { /** * Asserts that the actual timelines are the same to the expected timelines. This assert differs - * from testing equality by not comparing period ids which may be different due to id mapping of - * child source period ids. + * from testing equality by not comparing: + * + *

      + *
    • Period IDs, which may be different due to ID mapping of child source period IDs. + *
    • Shuffle order, which by default is random and non-deterministic. + *
    * * @param actualTimelines A list of actual {@link Timeline timelines}. * @param expectedTimelines A list of expected {@link Timeline timelines}. */ public static void assertTimelinesSame( List actualTimelines, List expectedTimelines) { - assertThat(actualTimelines).hasSize(expectedTimelines.size()); - for (int i = 0; i < actualTimelines.size(); i++) { - assertThat(new NoUidTimeline(actualTimelines.get(i))) - .isEqualTo(new NoUidTimeline(expectedTimelines.get(i))); - } + assertThat(actualTimelines) + .comparingElementsUsing( + Correspondence.from( + TestUtil::timelinesAreSame, "is equal to (ignoring Window.uid and Period.uid)")) + .containsExactlyElementsIn(expectedTimelines) + .inOrder(); + } + + /** + * Returns true if {@code thisTimeline} is equal to {@code thatTimeline}, ignoring {@link + * Timeline.Window#uid} and {@link Timeline.Period#uid} values, and shuffle order. + */ + public static boolean timelinesAreSame(Timeline thisTimeline, Timeline thatTimeline) { + return new NoUidOrShufflingTimeline(thisTimeline) + .equals(new NoUidOrShufflingTimeline(thatTimeline)); } /** @@ -494,4 +509,68 @@ public static List getPublicMethods(Class clazz) { return list; } + + private static final class NoUidOrShufflingTimeline extends Timeline { + + private final Timeline delegate; + + public NoUidOrShufflingTimeline(Timeline timeline) { + this.delegate = timeline; + } + + @Override + public int getWindowCount() { + return delegate.getWindowCount(); + } + + @Override + public int getNextWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + return delegate.getNextWindowIndex(windowIndex, repeatMode, /* shuffleModeEnabled= */ false); + } + + @Override + public int getPreviousWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + return delegate.getPreviousWindowIndex( + windowIndex, repeatMode, /* shuffleModeEnabled= */ false); + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + return delegate.getLastWindowIndex(/* shuffleModeEnabled= */ false); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + return delegate.getFirstWindowIndex(/* shuffleModeEnabled= */ false); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + delegate.getWindow(windowIndex, window, defaultPositionProjectionUs); + window.uid = 0; + return window; + } + + @Override + public int getPeriodCount() { + return delegate.getPeriodCount(); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + delegate.getPeriod(periodIndex, period, setIds); + period.uid = 0; + return period; + } + + @Override + public int getIndexOfPeriod(Object uid) { + return delegate.getIndexOfPeriod(uid); + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + return 0; + } + } } diff --git a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java index 29c3d09c57b..658e9f56bec 100644 --- a/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java +++ b/libraries/test_utils_robolectric/src/main/java/androidx/media3/test/utils/robolectric/TestPlayerRunHelper.java @@ -184,41 +184,6 @@ public static ExoPlaybackException runUntilError(ExoPlayer player) throws Timeou return checkNotNull(player.getPlayerError()); } - /** - * Runs tasks of the main {@link Looper} until {@link - * ExoPlayer.AudioOffloadListener#onExperimentalOffloadSchedulingEnabledChanged} is called or a - * playback error occurs. - * - *

    If a playback error occurs it will be thrown wrapped in an {@link IllegalStateException}. - * - * @param player The {@link Player}. - * @return The new offloadSchedulingEnabled state. - * @throws TimeoutException If the {@link RobolectricUtil#DEFAULT_TIMEOUT_MS default timeout} is - * exceeded. - */ - public static boolean runUntilReceiveOffloadSchedulingEnabledNewState(ExoPlayer player) - throws TimeoutException { - verifyMainTestThread(player); - AtomicReference<@NullableType Boolean> offloadSchedulingEnabledReceiver = - new AtomicReference<>(); - ExoPlayer.AudioOffloadListener listener = - new ExoPlayer.AudioOffloadListener() { - @Override - public void onExperimentalOffloadSchedulingEnabledChanged( - boolean offloadSchedulingEnabled) { - offloadSchedulingEnabledReceiver.set(offloadSchedulingEnabled); - } - }; - player.addAudioOffloadListener(listener); - runMainLooperUntil( - () -> offloadSchedulingEnabledReceiver.get() != null || player.getPlayerError() != null); - player.removeAudioOffloadListener(listener); - if (player.getPlayerError() != null) { - throw new IllegalStateException(player.getPlayerError()); - } - return checkNotNull(offloadSchedulingEnabledReceiver.get()); - } - /** * Runs tasks of the main {@link Looper} until {@link * ExoPlayer.AudioOffloadListener#onExperimentalSleepingForOffloadChanged(boolean)} is called or a diff --git a/libraries/ui/src/main/java/androidx/media3/ui/PlayerControlView.java b/libraries/ui/src/main/java/androidx/media3/ui/PlayerControlView.java index 7ab349bd1dd..8ec6d274983 100644 --- a/libraries/ui/src/main/java/androidx/media3/ui/PlayerControlView.java +++ b/libraries/ui/src/main/java/androidx/media3/ui/PlayerControlView.java @@ -1811,7 +1811,13 @@ public void onBindViewHolder(SubSettingViewHolder holder, int position) { if (position < playbackSpeedTexts.length) { holder.textView.setText(playbackSpeedTexts[position]); } - holder.checkView.setVisibility(position == selectedIndex ? VISIBLE : INVISIBLE); + if (position == selectedIndex) { + holder.itemView.setSelected(true); + holder.checkView.setVisibility(VISIBLE); + } else { + holder.itemView.setSelected(false); + holder.checkView.setVisibility(INVISIBLE); + } holder.itemView.setOnClickListener( v -> { if (position != selectedIndex) { diff --git a/libraries/ui_leanback/src/main/java/androidx/media3/ui/leanback/LeanbackPlayerAdapter.java b/libraries/ui_leanback/src/main/java/androidx/media3/ui/leanback/LeanbackPlayerAdapter.java index 77d25ce9dcb..84a8c9eb75b 100644 --- a/libraries/ui_leanback/src/main/java/androidx/media3/ui/leanback/LeanbackPlayerAdapter.java +++ b/libraries/ui_leanback/src/main/java/androidx/media3/ui/leanback/LeanbackPlayerAdapter.java @@ -236,11 +236,6 @@ public void surfaceDestroyed(SurfaceHolder surfaceHolder) { // Player.Listener implementation. - @Override - public void onPlaybackStateChanged(@Player.State int playbackState) { - notifyStateChanged(); - } - @Override public void onPlayerError(PlaybackException error) { Callback callback = getCallback(); @@ -285,5 +280,13 @@ public void onVideoSizeChanged(VideoSize videoSize) { int scaledWidth = Math.round(videoSize.width * videoSize.pixelWidthHeightRatio); getCallback().onVideoSizeChanged(LeanbackPlayerAdapter.this, scaledWidth, videoSize.height); } + + @Override + public void onEvents(Player player, Player.Events events) { + if (events.containsAny( + Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAYBACK_STATE_CHANGED)) { + notifyStateChanged(); + } + } } }