From e665edc863970e7b60bf6f4be4bb3c2c342ec8dc Mon Sep 17 00:00:00 2001 From: olly Date: Tue, 7 Jun 2022 20:23:51 +0000 Subject: [PATCH 01/40] Fix handling of content URIs by demo apps PiperOrigin-RevId: 453510883 (cherry picked from commit 96274bfd98b45347e6a06059465625e01e952158) --- demos/main/src/main/AndroidManifest.xml | 6 ++++++ demos/surface/src/main/AndroidManifest.xml | 6 ++++++ demos/transformer/src/main/AndroidManifest.xml | 6 ++++++ 3 files changed, 18 insertions(+) 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/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 @@ + + + + + + Date: Fri, 10 Jun 2022 14:29:55 +0000 Subject: [PATCH 02/40] Fix permissions request for media provider content URIs We need to request the external storage permission to access these PiperOrigin-RevId: 454160546 (cherry picked from commit b5f53e771095f2c3c521008cb2c6555f5905b163) --- .../androidx/media3/common/util/Util.java | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) 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..b25ebe933fb 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; @@ -219,8 +220,8 @@ public static boolean maybeRequestReadExternalStoragePermission(Activity activit return false; } for (Uri uri : uris) { - if (isLocalFileUri(uri)) { - return requestExternalStoragePermission(activity); + if (maybeRequestReadExternalStoragePermission(activity, uri)) { + return true; } } return false; @@ -245,18 +246,39 @@ public static boolean maybeRequestReadExternalStoragePermission( 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 Util.SDK_INT >= 23 && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) + ? requestExternalStoragePermission(activity) + : false; + } + + 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. From c4479c719fc164553b01a4ebeb4c48128522fd86 Mon Sep 17 00:00:00 2001 From: olly Date: Wed, 15 Jun 2022 14:08:00 +0000 Subject: [PATCH 03/40] Cleanup: Remove unnecessary self-refs in Util PiperOrigin-RevId: 455121899 (cherry picked from commit 99cdc515ae13b2fecba620102a854fc6b312c3c9) --- .../androidx/media3/common/util/Util.java | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) 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 b25ebe933fb..8d107511450 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 @@ -200,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); @@ -216,7 +216,7 @@ 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) { @@ -239,7 +239,7 @@ 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) { @@ -261,7 +261,7 @@ public static boolean maybeRequestReadExternalStoragePermission( } private static boolean maybeRequestReadExternalStoragePermission(Activity activity, Uri uri) { - return Util.SDK_INT >= 23 && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) + return SDK_INT >= 23 && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) ? requestExternalStoragePermission(activity) : false; } @@ -287,7 +287,7 @@ private static boolean isMediaStoreExternalContentUri(Uri uri) { * @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; } @@ -672,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(); } @@ -1734,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 @@ -2027,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: @@ -2367,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(); } /** @@ -2442,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); } @@ -2461,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); @@ -2510,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. @@ -2531,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); @@ -2767,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) From 56cfa66d3bab572e3549c8d6d39479e180ba6644 Mon Sep 17 00:00:00 2001 From: christosts Date: Wed, 15 Jun 2022 17:05:54 +0000 Subject: [PATCH 04/40] Misc javadoc fix. PiperOrigin-RevId: 455157744 (cherry picked from commit 12e756273091f64152b0f053bee401684f9edbf6) --- .../main/java/androidx/media3/exoplayer/text/TextOutput.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From 265b865b26553badd3c8eca338eab619772057e8 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 16 Jun 2022 14:39:46 +0000 Subject: [PATCH 05/40] Misc improvement in Util #minor-release PiperOrigin-RevId: 455380010 (cherry picked from commit 7563bd2792d85c61223c12dd0c4c5a77d1b75b13) --- .../src/main/java/androidx/media3/common/util/Util.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8d107511450..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 @@ -261,9 +261,9 @@ public static boolean maybeRequestReadExternalStoragePermission( } private static boolean maybeRequestReadExternalStoragePermission(Activity activity, Uri uri) { - return SDK_INT >= 23 && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) - ? requestExternalStoragePermission(activity) - : false; + return SDK_INT >= 23 + && (isLocalFileUri(uri) || isMediaStoreExternalContentUri(uri)) + && requestExternalStoragePermission(activity); } private static boolean isMediaStoreExternalContentUri(Uri uri) { From 4e51ef5a2ccd31bd926653a475ebd5daa74b8e89 Mon Sep 17 00:00:00 2001 From: olly Date: Mon, 20 Jun 2022 16:46:15 +0100 Subject: [PATCH 06/40] Fix parsing H265 short term reference picture sets Issue: google/ExoPlayer#10316 PiperOrigin-RevId: 456084302 (cherry picked from commit 6dc85dc241dca8f0783d3ffbb3448840b0e75b60) --- RELEASENOTES.md | 2 + .../media3/extractor/NalUnitUtil.java | 98 ++++++++++++++++--- .../media3/extractor/NalUnitUtilTest.java | 26 +++++ 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index febe027e2e9..033e158b693 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,8 @@ * 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)). * RTSP: * Add RTP reader for H263 ([#63](https://github.com/androidx/media/pull/63)). 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/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++) { From 59fbb45506577d884f017699a45cd532b2070d21 Mon Sep 17 00:00:00 2001 From: tonihei Date: Tue, 21 Jun 2022 15:58:04 +0100 Subject: [PATCH 07/40] Clear pending doSomeWork messages when sleeping for offload The offload sleeping stops as soon as a new DO_SOME_WORK message is handled (because this indicates an expected change in rendering and we want to stop sleeping until we know it's safe to do so). Every exit path from doSomeWork needs to clear other pending DO_SOME_WORK messages as these requests have already been handled by the current method invocation. This currently doesn't happen from the offload sleeping return path and a previously queued DO_SOME_WORK message can immediately wake up the rendering loop again. Fix this by moving the message removal to the beginning of the doSomeWork method (as it prevents forgetting it in one of the exit paths later). PiperOrigin-RevId: 456259715 (cherry picked from commit a7649b639cef242f70cf832f5dbf56514b7e8c4b) --- .../androidx/media3/exoplayer/ExoPlayerImplInternal.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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..0b37727ecf5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -960,12 +960,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; } @@ -1088,8 +1090,6 @@ && isLoadingPossible()) { 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); } if (playbackInfo.sleepingForOffload != sleepingForOffload) { playbackInfo = playbackInfo.copyWithSleepingForOffload(sleepingForOffload); @@ -1125,7 +1125,6 @@ private boolean shouldUseLivePlaybackSpeedControl( } private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { - handler.removeMessages(MSG_DO_SOME_WORK); handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); } From 2f1260e346df471db8abbf35e4cfb837a93a7ece Mon Sep 17 00:00:00 2001 From: olly Date: Thu, 23 Jun 2022 14:31:27 +0100 Subject: [PATCH 08/40] Inform ProgressiveMediaPeriod of known length earlier PiperOrigin-RevId: 456753343 (cherry picked from commit 1d2ad39a4d4778124a6b87c3d3a7a4415eeb8865) --- .../source/ProgressiveMediaPeriod.java | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) 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..1f83a065fca 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 @@ -135,7 +135,7 @@ interface Listener { private boolean seenFirstTrackSelection; private boolean notifyDiscontinuity; private int enabledTrackCount; - private long length; + private boolean isLengthKnown; private long lastSeekPositionUs; private long pendingResetPositionUs; @@ -201,7 +201,6 @@ public ProgressiveMediaPeriod( 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; } @@ -578,7 +577,6 @@ public void onLoadCompleted( /* trackSelectionData= */ null, /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs); - copyLengthFromLoader(loadable); loadingFinished = true; Assertions.checkNotNull(callback).onContinueLoadingRequested(this); } @@ -607,7 +605,6 @@ public void onLoadCanceled( /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs); if (!released) { - copyLengthFromLoader(loadable); for (SampleQueue sampleQueue : sampleQueues) { sampleQueue.reset(); } @@ -624,7 +621,6 @@ public LoadErrorAction onLoadError( long loadDurationMs, IOException error, int errorCount) { - copyLengthFromLoader(loadable); StatsDataSource dataSource = loadable.dataSource; LoadEventInfo loadEventInfo = new LoadEventInfo( @@ -710,6 +706,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 +733,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) { @@ -789,12 +789,6 @@ private void maybeFinishPrepare() { Assertions.checkNotNull(callback).onPrepared(this); } - private void copyLengthFromLoader(ExtractingLoadable loadable) { - if (length == C.LENGTH_UNSET) { - length = loadable.length; - } - } - private void startLoading() { ExtractingLoadable loadable = new ExtractingLoadable( @@ -840,7 +834,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; @@ -970,7 +964,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 +981,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 +999,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; From 7683c8bf6cb5edc1f92acd9196884a9ff71b761b Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 27 Jun 2022 10:04:19 +0100 Subject: [PATCH 09/40] Rename shouldUseDummySurface to shouldUsePlaceholderSurface This was likely missed in https://github.com/androidx/media/commit/33373d0d0a159ad9c9c3590c838098c4c1530910. PiperOrigin-RevId: 457422574 (cherry picked from commit 8e716d6804e4c5b3417a19e1ef3c07cf2810aefd) --- .../media3/exoplayer/video/MediaCodecVideoRenderer.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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..073f60fa2b9 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) From 9274f08b896384ad7bf141079cb1e946e37b25db Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 27 Jun 2022 09:34:56 +0000 Subject: [PATCH 10/40] Clean up offload state tracking 1. The offloadSchedulingEnabled value doesn't need to be in PlaybackInfo because it's never updated in EPII. 2. The sleepingForOffload value in EPII wasn't updated explicitly (just via the return value of a method). It was also only meant to be enabled while the player is actively playing, but confusingly triggered from a path where the player may theoretically be buffering as well. 3. The offload sleeping (=not scheduling doSomeWork) was interwoven into the actual scheduling code making it slightly hard to follow. This can be improved slightly by keeping the offload sleeping decision and the scheduling separate. PiperOrigin-RevId: 457427293 (cherry picked from commit 5c2752b4a92a076babc0bc1ff5651a306c20e693) --- .../media3/exoplayer/ExoPlayerImpl.java | 9 ++-- .../exoplayer/ExoPlayerImplInternal.java | 40 ++++++---------- .../media3/exoplayer/PlaybackInfo.java | 46 ------------------- .../media3/exoplayer/ExoPlayerTest.java | 42 ++--------------- .../exoplayer/MediaPeriodQueueTest.java | 1 - .../robolectric/TestPlayerRunHelper.java | 35 -------------- 6 files changed, 23 insertions(+), 150 deletions(-) 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..4cdb4e93ea1 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -433,6 +433,9 @@ public DeviceComponent getDeviceComponent() { public void experimentalSetOffloadSchedulingEnabled(boolean offloadSchedulingEnabled) { verifyApplicationThread(); internalPlayer.experimentalSetOffloadSchedulingEnabled(offloadSchedulingEnabled); + for (AudioOffloadListener listener : audioOffloadListeners) { + listener.onExperimentalOffloadSchedulingEnabledChanged(offloadSchedulingEnabled); + } } @Override @@ -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 0b37727ecf5..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); } } @@ -1080,22 +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); - } + 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(); } @@ -1128,15 +1128,6 @@ private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { 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); @@ -1467,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/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 784f6c23df3..74c82d008ba 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -59,7 +59,6 @@ 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; @@ -9635,47 +9634,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 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/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 From 36e92aeeab536b2d57c8cfbc85bf62409f8dc761 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 28 Jun 2022 09:30:11 +0000 Subject: [PATCH 11/40] Fix release notes related to track selection renames and deletions Issue: google/ExoPlayer#10363 PiperOrigin-RevId: 457679928 (cherry picked from commit a67db3140947ec42e27bca516147483969cf1a01) --- RELEASENOTES.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 033e158b693..58f5db7b49d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -48,7 +48,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 @@ -213,10 +215,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. From cad5134f601f465457f61e2ec643937e15b5cb46 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 28 Jun 2022 09:33:29 +0000 Subject: [PATCH 12/40] Fix typo in the media3 1.0.0-alpha02 / ExoPlayer 2.17.0 release notes PiperOrigin-RevId: 457680579 (cherry picked from commit b7241d4eb32fc049b2c6765a356ff5f18717f825) --- RELEASENOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 58f5db7b49d..32d13f99c67 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -429,7 +429,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 From 968169c78464c817fdc220054ed841a7fe713600 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 28 Jun 2022 12:15:03 +0000 Subject: [PATCH 13/40] Use a helper function and Truth Correspondence instead of NoUidTimeline NoUidTimeline still exists as a private detail of TestUtil, but it no longer extends ForwardingTimeline because the interactions are quite hard to reason about. #minor-release PiperOrigin-RevId: 457703593 (cherry picked from commit 2a2d9e360baa6e1a44be53c39fbcd69079f34dcc) --- .../media3/exoplayer/ExoPlayerTest.java | 4 +- .../androidx/media3/test/utils/Action.java | 10 +-- .../test/utils/ExoPlayerTestRunner.java | 8 +-- .../media3/test/utils/NoUidTimeline.java | 51 --------------- .../androidx/media3/test/utils/TestUtil.java | 63 +++++++++++++++++-- 5 files changed, 69 insertions(+), 67 deletions(-) delete mode 100644 libraries/test_utils/src/main/java/androidx/media3/test/utils/NoUidTimeline.java 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 74c82d008ba..0f0d0f1527f 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -53,6 +53,7 @@ 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; @@ -156,7 +157,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; @@ -12264,6 +12264,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/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/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/TestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java index a59377c27dc..f6ac5ecb997 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; @@ -207,11 +208,20 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { */ 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. + */ + public static boolean timelinesAreSame(Timeline thisTimeline, Timeline thatTimeline) { + return new NoUidTimeline(thisTimeline).equals(new NoUidTimeline(thatTimeline)); } /** @@ -494,4 +504,47 @@ public static List getPublicMethods(Class clazz) { return list; } + + private static final class NoUidTimeline extends Timeline { + + private final Timeline delegate; + + public NoUidTimeline(Timeline timeline) { + this.delegate = timeline; + } + + @Override + public int getWindowCount() { + return delegate.getWindowCount(); + } + + @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; + } + } } From 8b2b7868f37b23b1a4f91ea2a804f4acfb218761 Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 28 Jun 2022 12:15:54 +0000 Subject: [PATCH 14/40] Consider shuffle order in Timeline.equals() Previously two timelines that differed only in shuffle order were considered equal, which resulted in no call to Player.Listener.onTimelineChanged when calling ExoPlayer.setShuffleOrder. This in turn resulted in no call to MediaControllerCompat.Callback.onQueueChanged. Also make a small fix inside ExoPlayerImpl.setShuffleOrder, to ensure that the new shuffle order is used when constructing the masked timeline. Issue: google/ExoPlayer#9889 #minor-release PiperOrigin-RevId: 457703727 (cherry picked from commit 6f9ce4056cc427076ba48189ab192e65234da4d8) --- RELEASENOTES.md | 5 ++ libraries/common/build.gradle | 1 + .../java/androidx/media3/common/Timeline.java | 28 +++++++++++ .../androidx/media3/common/TimelineTest.java | 45 +++++++++++++++++ .../media3/exoplayer/ExoPlayerImpl.java | 2 +- .../media3/exoplayer/ExoPlayerTest.java | 48 +++++++++++++++++++ .../media3/test/utils/FakeTimeline.java | 26 +++++++--- .../androidx/media3/test/utils/TestUtil.java | 38 ++++++++++++--- 8 files changed, 180 insertions(+), 13 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 32d13f99c67..7b8eb97098a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,11 @@ ### Unreleased changes +* 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)). * Extractors: * Add support for AVI ([#2092](https://github.com/google/ExoPlayer/issues/2092)). 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/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/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 4cdb4e93ea1..38620c0653e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -710,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( @@ -718,7 +719,6 @@ public void setShuffleOrder(ShuffleOrder shuffleOrder) { maskWindowPositionMsOrGetPeriodPositionUs( timeline, getCurrentMediaItemIndex(), getCurrentPosition())); pendingOperationAcks++; - this.shuffleOrder = shuffleOrder; internalPlayer.setShuffleOrder(shuffleOrder); updatePlaybackInfo( newPlaybackInfo, 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 0f0d0f1527f..7c1382bba9d 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -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; @@ -6511,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}; 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/TestUtil.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/TestUtil.java index f6ac5ecb997..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 @@ -200,8 +200,12 @@ 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}. @@ -218,10 +222,11 @@ public static void assertTimelinesSame( /** * Returns true if {@code thisTimeline} is equal to {@code thatTimeline}, ignoring {@link - * Timeline.Window#uid} and {@link Timeline.Period#uid} values. + * Timeline.Window#uid} and {@link Timeline.Period#uid} values, and shuffle order. */ public static boolean timelinesAreSame(Timeline thisTimeline, Timeline thatTimeline) { - return new NoUidTimeline(thisTimeline).equals(new NoUidTimeline(thatTimeline)); + return new NoUidOrShufflingTimeline(thisTimeline) + .equals(new NoUidOrShufflingTimeline(thatTimeline)); } /** @@ -505,11 +510,11 @@ public static List getPublicMethods(Class clazz) { return list; } - private static final class NoUidTimeline extends Timeline { + private static final class NoUidOrShufflingTimeline extends Timeline { private final Timeline delegate; - public NoUidTimeline(Timeline timeline) { + public NoUidOrShufflingTimeline(Timeline timeline) { this.delegate = timeline; } @@ -518,6 +523,27 @@ 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); From 258d9361c480d12db8406a0763b00bf03b54e3a9 Mon Sep 17 00:00:00 2001 From: ibaker Date: Wed, 29 Jun 2022 16:10:11 +0000 Subject: [PATCH 15/40] Ensure TalkBack announces the selected playback speed in the UI menu Issue: google/ExoPlayer#10298 PiperOrigin-RevId: 457991028 (cherry picked from commit 3fc6a66527c6af940b45dd0d2ce1f3fb19491fa0) --- RELEASENOTES.md | 4 ++++ .../main/java/androidx/media3/ui/PlayerControlView.java | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7b8eb97098a..3ad47e910ef 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,10 @@ ([#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)). +* 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)). 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) { From 4d6781be2ca2abba09e261cff24a6d881df2decd Mon Sep 17 00:00:00 2001 From: rohks Date: Fri, 1 Jul 2022 09:39:11 +0000 Subject: [PATCH 16/40] Fix MP4 parser issue in reading length of URL array from esds boxes. As per MP4 spec, the length of URL array is a 8 bit number. #minor-release PiperOrigin-RevId: 458421436 (cherry picked from commit 42f13c331f8c7e28505edcd1e033c5525e5c22f2) --- .../main/java/androidx/media3/extractor/mp4/AtomParsers.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..b26366dae32 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 @@ -1659,7 +1659,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); From 1ec7148c9665140c4c03e74d1a9ebe8375f690bb Mon Sep 17 00:00:00 2001 From: rohks Date: Fri, 1 Jul 2022 09:52:45 +0000 Subject: [PATCH 17/40] Fix MP4 parser issue in reading bitrates from esds boxes. As per MP4 spec, bitrates in esds boxes can be a 32 bit number which doesn't fits in Java int type, so now reading it as a long value. Our class for holding media format, only allows bitrates value to be an int as we don't expect the bitrates to be greater than or equal to 2^31. So we're limiting the values for bitrates to Integer.MAX_VALUE. PiperOrigin-RevId: 458423162 (cherry picked from commit 21638fa3784078170af3be545316e062313a30b5) --- RELEASENOTES.md | 2 ++ .../media3/extractor/mp4/AtomParsers.java | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3ad47e910ef..bdd71a9fa0c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,8 @@ ([#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)). * UI: * Ensure TalkBack announces the currently active speed option in the playback controls menu 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 b26366dae32..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(); @@ -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; From 23888c1217e5f1e9239b04c81ec205eac65f274f Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 1 Jul 2022 13:53:11 +0000 Subject: [PATCH 18/40] Use ContextCompat.getMainExecutor when calling MediaBrowser methods This allows the service to be switched to run in another process and the app still works the same as if it is running in the same process. Issue: androidx/media#100 PiperOrigin-RevId: 458460005 (cherry picked from commit c9abe70259adabd1523b0e2aa0a4df74d78339d9) --- .../main/java/androidx/media3/demo/session/MainActivity.kt | 3 ++- .../androidx/media3/demo/session/PlayableFolderActivity.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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) ) } From 3de3f4175746aedd6b2ea869e90025683cca5d9a Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 1 Jul 2022 14:27:24 +0000 Subject: [PATCH 19/40] Document custom commands in the DefaultMediaNotificationProvider Issue: androidx/media#103 #minor-release PiperOrigin-RevId: 458465479 (cherry picked from commit 2c0806814bd28612834b51b8489a138e5eb10ecf) --- .../DefaultMediaNotificationProvider.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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..3605fec1002 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,14 @@ *
  • {@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, ControllerInfo + * controller, 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 +227,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, 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, ControllerInfo)} + * also. + * * @param playerCommands The available player commands. * @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of * commands}. From 1def7b5a369478878cf056e0f6c2ecd9b97d90fd Mon Sep 17 00:00:00 2001 From: tonihei Date: Fri, 1 Jul 2022 15:28:39 +0000 Subject: [PATCH 20/40] Only consider enabled tracks in ProgressiveMediaPeriod.bufferedPosition ProgressiveMediaPeriod loads all available tracks into SampleStreams (because it needs to read the data anyway and it allows easy activation of tracks without reloading). However, the SampleStreams for disabled tracks are not read and no one if waiting for them. The buffered position is used for user-visible state (e.g. in the UI) and to check how much data is already buffered to decide when to stop buffering (using LoadControl). Both values benefit from only using the actually enabled tracks to better reflect what is available for playback at the moment. Issue:Issue: google/ExoPlayer#10361 PiperOrigin-RevId: 458475038 (cherry picked from commit ceb23e69bbdc075df96943e5c29d52cf1d492db9) --- RELEASENOTES.md | 2 + .../source/ProgressiveMediaPeriod.java | 51 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bdd71a9fa0c..b2cd791e8df 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -7,6 +7,8 @@ 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)). * Extractors: * Add support for AVI ([#2092](https://github.com/google/ExoPlayer/issues/2092)). 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 1f83a065fca..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; @@ -193,8 +194,7 @@ public ProgressiveMediaPeriod( onContinueLoadingRequestedRunnable = () -> { if (!released) { - Assertions.checkNotNull(callback) - .onContinueLoadingRequested(ProgressiveMediaPeriod.this); + checkNotNull(callback).onContinueLoadingRequested(ProgressiveMediaPeriod.this); } }; handler = Util.createHandlerForCurrentLooper(); @@ -366,7 +366,7 @@ public boolean isLoading() { @Override public long getNextLoadPositionUs() { - return enabledTrackCount == 0 ? C.TIME_END_OF_SOURCE : getBufferedPositionUs(); + return getBufferedPositionUs(); } @Override @@ -382,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; @@ -393,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 @@ -536,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() { @@ -550,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,7 +580,7 @@ public void onLoadCompleted( /* mediaStartTimeUs= */ loadable.seekTimeUs, durationUs); loadingFinished = true; - Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + checkNotNull(callback).onContinueLoadingRequested(this); } @Override @@ -609,7 +611,7 @@ public void onLoadCanceled( sampleQueue.reset(); } if (enabledTrackCount > 0) { - Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + checkNotNull(callback).onContinueLoadingRequested(this); } } } @@ -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,7 +788,7 @@ private void maybeFinishPrepare() { } trackState = new TrackState(new TrackGroupArray(trackArray), trackIsAudioVideoFlags); prepared = true; - Assertions.checkNotNull(callback).onPrepared(this); + checkNotNull(callback).onPrepared(this); } private void startLoading() { @@ -801,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); @@ -898,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; } @@ -914,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 { @@ -1058,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); From 1bbaec9c1b95f8f768769c5bf7d972df6bd0ac6b Mon Sep 17 00:00:00 2001 From: Marc Baechinger Date: Mon, 4 Jul 2022 19:56:41 +0000 Subject: [PATCH 21/40] Merge pull request #96 from fengdai:release PiperOrigin-RevId: 458883441 (cherry picked from commit 8e5af4a3aa43a3f2e0fd96415adf800a0abf8507) --- .../src/main/java/androidx/media3/test/utils/StubPlayer.java | 1 + 1 file changed, 1 insertion(+) 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(); } From 1f1460a152b0f179552b00b56c35a1b3e9f092fa Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 5 Jul 2022 23:21:59 +0000 Subject: [PATCH 22/40] Use mediaId as contentId if available This is to be consistent with what cast `QueueMediaItem` is doing. If a contentId is not available the contentUrl is used as the ID. #minor-release PiperOrigin-RevId: 459133323 (cherry picked from commit 0a9f9007c66ca725959b3fe70311dd72dc086346) --- .../cast/DefaultMediaItemConverter.java | 7 +++- .../cast/DefaultMediaItemConverterTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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/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"); + } } From d2027a8338513e4a23258776712560fc94a5f7ac Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 6 Jul 2022 09:46:27 +0000 Subject: [PATCH 23/40] Exclude HEVC 10bit profile on Pixel 1. This profile is declared as supported although it isn't. Issue: google/ExoPlayer#10345 Issue: google/ExoPlayer#3537 #minor-release PiperOrigin-RevId: 459205512 (cherry picked from commit 656eaf74d13d5c35b67ef16b21c6ddbdd1266fc8) --- .../exoplayer/mediacodec/MediaCodecInfo.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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)); + } } From bc4703699394924f387fd90e63ff2119d36cb87c Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Thu, 7 Jul 2022 16:43:14 +0000 Subject: [PATCH 24/40] Merge pull request #10260 from sr1990:clearkey_parse_licenseurl PiperOrigin-RevId: 459215225 (cherry picked from commit f00f93a96e0c7fa4f37e32e280b073d1cef2649e) --- RELEASENOTES.md | 3 ++ .../androidx/media3/common/DrmInitData.java | 3 ++ .../dash/manifest/DashManifestParser.java | 34 ++++++++++++++++++- .../dash/manifest/DashManifestParserTest.java | 34 +++++++++++++++++++ .../mpd/sample_mpd_clear_key_license_url | 30 ++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 libraries/test_data/src/test/assets/media/mpd/sample_mpd_clear_key_license_url diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b2cd791e8df..13bbab32f7f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -16,6 +16,9 @@ ([#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 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/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/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/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 + + + + + + From 87b817b0f9d353672ffe43037f75c534ae58a496 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 6 Jul 2022 11:02:12 +0000 Subject: [PATCH 25/40] Fix incorrect link tags PiperOrigin-RevId: 459215618 (cherry picked from commit 87adb88f57afb050a96a9c67dd9eb55fb3a6706c) --- .../DefaultMediaNotificationProvider.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 3605fec1002..0059a9d99af 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -72,10 +72,11 @@ *

    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, ControllerInfo - * controller, 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. + * 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

    * @@ -230,10 +231,10 @@ public final boolean handleCustomCommand(MediaSession session, String action, Bu *

    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, 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, ControllerInfo)} - * also. + * 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 From 9af65a909dbd2e630d2b52c4ccad904c73297fc3 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 7 Jul 2022 10:22:56 +0000 Subject: [PATCH 26/40] Don't block AudioTrack when waiting for previous release We wait until a previous AudioTrack has been released before creating a new one. This is currently done with a thread block operation, which may cause ANRs in the extreme case when someone attempts to release the player while this is still blocked. The problem can be avoided by just returning false from DefaultAudioSink.handleBuffer to try again until the previous AudioTrack is released. Reproduction steps to force the issue: 1. Add Thread.sleep(10000); to the AudioTrack release thread. 2. Add this to the demo app: private int positionMs = 0; Handler handler = new Handler(); handler.post(new Runnable() { @Override public void run() { player.seekTo(positionMs++); if (positionMs == 10) { player.release(); } else { handler.postDelayed(this, 1000); } } 3. Observe Player release timeout exception. These steps can't be easily captured in a unit test as we can't artifically delay the AudioTrack release from the test. Issue: google/ExoPlayer#10057 PiperOrigin-RevId: 459468912 (cherry picked from commit a83ab05aeceb6c99f3b7b19d6fedd20f317e0aa6) --- .../exoplayer/audio/DefaultAudioSink.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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. From f9e082743d8862dba07ca123644552a8c828b7e2 Mon Sep 17 00:00:00 2001 From: christosts Date: Thu, 7 Jul 2022 12:17:36 +0000 Subject: [PATCH 27/40] Add missing Nullable annotation PiperOrigin-RevId: 459485334 (cherry picked from commit cb87b7432f4c08a597d048114b5006d09de29da9) --- .../media3/exoplayer/video/MediaCodecVideoRenderer.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 073f60fa2b9..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 @@ -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; From c40d669c51b464795c9e446616cf663774ff5d18 Mon Sep 17 00:00:00 2001 From: rohks Date: Thu, 7 Jul 2022 13:04:29 +0000 Subject: [PATCH 28/40] Add tests for extracting MP4 with large bitrates Also added the test to `MP4PlaybackTest`. PiperOrigin-RevId: 459492188 (cherry picked from commit 05e728a31eb85b82610a5eb83622ee2670582a26) --- .../exoplayer/e2etest/Mp4PlaybackTest.java | 1 + .../mp4/FragmentedMp4ExtractorTest.java | 9 + ...ample_fragmented_large_bitrates.mp4.0.dump | 339 ++++++++++++++++++ ...ample_fragmented_large_bitrates.mp4.1.dump | 279 ++++++++++++++ ...ample_fragmented_large_bitrates.mp4.2.dump | 219 +++++++++++ ...ample_fragmented_large_bitrates.mp4.3.dump | 159 ++++++++ ...ted_large_bitrates.mp4.unknown_length.dump | 339 ++++++++++++++++++ .../mp4/sample_fragmented_large_bitrates.mp4 | Bin 0 -> 106099 bytes .../sample_fragmented_large_bitrates.mp4.dump | 82 +++++ 9 files changed, 1427 insertions(+) create mode 100644 libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.0.dump create mode 100644 libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.1.dump create mode 100644 libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.2.dump create mode 100644 libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.3.dump create mode 100644 libraries/test_data/src/test/assets/extractordumps/mp4/sample_fragmented_large_bitrates.mp4.unknown_length.dump create mode 100644 libraries/test_data/src/test/assets/media/mp4/sample_fragmented_large_bitrates.mp4 create mode 100644 libraries/test_data/src/test/assets/playbackdumps/mp4/sample_fragmented_large_bitrates.mp4.dump 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/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/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 0000000000000000000000000000000000000000..39fd4c18cf250d002c505ec71e22e7894404f2b9 GIT binary patch literal 106099 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 c*MGgbqG)!!t$#GOCpEwT Date: Mon, 11 Jul 2022 15:49:33 +0000 Subject: [PATCH 29/40] Listen to playWhenReady changes in LeanbackPlayerAdapter #minor-release Issue: google/ExoPlayer#10420 PiperOrigin-RevId: 460223064 (cherry picked from commit 4eb34e4c58e9cdfc804a5e3347ef30e9991c0186) --- RELEASENOTES.md | 3 +++ .../media3/ui/leanback/LeanbackPlayerAdapter.java | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 13bbab32f7f..33d33816ab1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -26,6 +26,9 @@ * RTSP: * Add RTP reader for H263 ([#63](https://github.com/androidx/media/pull/63)). +* Leanback extension: + * Listen to `playWhenReady` changes in `LeanbackAdapter` + ([10420](https://github.com/google/ExoPlayer/issues/10420)). ### 1.0.0-beta01 (2022-06-16) 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(); + } + } } } From fe6baee7759310a57fdca3b2870593953f31c0eb Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 11 Jul 2022 23:22:27 +0000 Subject: [PATCH 30/40] Use the public MediaItem in the timeline of CastPlayer The media item needs to be assigned to `Window.mediaItem` in `CastTimeline.setWindow`. For this the `MediaItem` needs to be available in the timeline. When a `MediaItem` is passed to the `set/addMediaItems` method, we can't yet know the Cast `MediaQueueItem.itemId` that is generated on the device and arrives with an async update of the `RemoteMediaClient` state. Hence in the `CastTimelineTracker`, we need to store the `MediaItem` by Casts's `MediaItem.contentId`. When we then receive the updated queue, we look the media item up by the content ID to augment the `ItemData` that is available in the `CastTimeline`. Issue: androidx/media#25 Issue: google/ExoPlayer#8212 #minor-release PiperOrigin-RevId: 460325235 (cherry picked from commit 30fbc3a27d1c2b673c3f0a6f1c8956e183b11952) --- RELEASENOTES.md | 5 + .../java/androidx/media3/cast/CastPlayer.java | 39 ++-- .../androidx/media3/cast/CastTimeline.java | 47 ++++- .../media3/cast/CastTimelineTracker.java | 86 +++++++- .../androidx/media3/cast/CastPlayerTest.java | 195 +++++++++-------- .../media3/cast/CastTimelineTrackerTest.java | 197 +++++++++++++++++- 6 files changed, 436 insertions(+), 133 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 33d33816ab1..c7b9bba99ca 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -29,6 +29,11 @@ * 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)). ### 1.0.0-beta01 (2022-06-16) 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..349507fd3f8 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -198,7 +198,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(); @@ -283,8 +283,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 +293,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 @@ -1020,14 +1019,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 +1036,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..d21fca26083 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.buildUpon().setTag(id).build(); 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/test/java/androidx/media3/cast/CastPlayerTest.java b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java index 31b7afd87ad..83273d2a9a1 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -63,6 +63,7 @@ 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; @@ -126,6 +127,7 @@ 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); @@ -388,7 +390,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()); @@ -424,32 +426,42 @@ public void setMediaItems_replaceExistingPlaylist_notifiesMediaItemTransition() String uri1 = "http://www.google.com/video1"; String uri2 = "http://www.google.com/video2"; firstPlaylist.add( - new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + new MediaItem.Builder() + .setUri(uri1) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(1) + .build()); firstPlaylist.add( - new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + new MediaItem.Builder() + .setUri(uri2) + .setMimeType(MimeTypes.APPLICATION_MP4) + .setTag(2) + .build()); ImmutableList secondPlaylist = ImmutableList.of( new MediaItem.Builder() .setUri(Uri.EMPTY) + .setTag(3) .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( - mediaItemCaptor.capture(), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + eq(firstPlaylist.get(1)), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockListener) + .onMediaItemTransition( + 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. @@ -459,18 +471,26 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( String uri1 = "http://www.google.com/video1"; String uri2 = "http://www.google.com/video2"; firstPlaylist.add( - new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + new MediaItem.Builder() + .setUri(uri1) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(1) + .build()); firstPlaylist.add( - new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + new MediaItem.Builder() + .setUri(uri2) + .setMimeType(MimeTypes.APPLICATION_MP4) + .setTag(2) + .build()); ImmutableList secondPlaylist = ImmutableList.of( new MediaItem.Builder() .setUri(Uri.EMPTY) .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(3) .build()); - castPlayer.setMediaItems( - firstPlaylist, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 2000L); updateTimeLine( firstPlaylist, /* mediaQueueItemIds= */ new int[] {1, 2}, @@ -481,8 +501,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 +513,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 +524,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, @@ -720,10 +739,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 +759,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 +794,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 +805,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 +845,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 +878,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 +889,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 +928,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 +959,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 +970,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 +1006,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 +1042,17 @@ public void seekTo_otherWindow_notifiesMediaItemTransition() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1234); + castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 1234); 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); } @Test @@ -1054,13 +1067,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 +1083,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 +1110,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 +1129,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 +1145,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 +1177,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 +1215,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 +1226,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 +1262,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 +1276,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 +1310,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 +1324,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 +1485,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 +1519,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 +1543,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()); } @@ -1782,6 +1792,7 @@ 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").build()) .setMimeType(MimeTypes.APPLICATION_MPD) .setTag(mediaQueueItemId) .build(); @@ -1821,8 +1832,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; } From fff99fe93f8d7d3d1aaa70094d96b71a9aa57f6a Mon Sep 17 00:00:00 2001 From: ibaker Date: Tue, 12 Jul 2022 14:06:27 +0000 Subject: [PATCH 31/40] Group COMMAND_SET_MEDIA_ITEM and COMMAND_CHANGE_MEDIA_ITEMS together I don't think it's useful to keep these in numerical order, it makes more sense to keep them grouped into a 'logical' ordering. #minor-release PiperOrigin-RevId: 460453464 (cherry picked from commit ad46cb1c81addfc20b7333a451741a717e85da18) --- api.txt | 2 +- .../src/main/java/androidx/media3/cast/CastPlayer.java | 4 ++-- .../src/main/java/androidx/media3/common/Player.java | 8 ++++---- .../java/androidx/media3/exoplayer/ExoPlayerImpl.java | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) 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/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java index 349507fd3f8..56f434461cc 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; 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/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 38620c0653e..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(); From 97716cd0a11ef3b703499d4b790ec1b9164c56e5 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 12 Jul 2022 16:04:45 +0000 Subject: [PATCH 32/40] Enable onMediaMetadataChanged in CastPlayer Issue: androidx/media#25 PiperOrigin-RevId: 460476841 (cherry picked from commit 6922bd58ee844cc8293ef885918d01e2d0fbc02b) --- RELEASENOTES.md | 2 + .../java/androidx/media3/cast/CastPlayer.java | 24 +++- .../androidx/media3/cast/CastPlayerTest.java | 112 +++++++++++++++++- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c7b9bba99ca..3ccb449b8c1 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -34,6 +34,8 @@ `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) 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 56f434461cc..acdd0fe8c72 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -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. @@ -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; @@ -425,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) { @@ -562,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 @@ -760,6 +773,7 @@ private void updateInternalStateAndNotifyIfChanged() { return; } int oldWindowIndex = this.currentWindowIndex; + MediaMetadata oldMediaMetadata = mediaMetadata; @Nullable Object oldPeriodUid = !getCurrentTimeline().isEmpty() @@ -771,6 +785,7 @@ private void updateInternalStateAndNotifyIfChanged() { boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged(); Timeline currentTimeline = getCurrentTimeline(); currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline); + mediaMetadata = getMediaMetadataInternal(); @Nullable Object currentPeriodUid = !currentTimeline.isEmpty() @@ -824,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(); } 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 83273d2a9a1..11bbf97f799 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -67,6 +67,7 @@ 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; @@ -107,7 +108,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 @@ -1042,7 +1043,9 @@ public void seekTo_otherWindow_notifiesMediaItemTransition() { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); + MediaMetadata firstMediaMetadata = castPlayer.getMediaMetadata(); castPlayer.seekTo(/* mediaItemIndex= */ 1, /* positionMs= */ 1234); + MediaMetadata secondMediaMetadata = castPlayer.getMediaMetadata(); InOrder inOrder = Mockito.inOrder(mockListener); inOrder @@ -1053,6 +1056,8 @@ public void seekTo_otherWindow_notifiesMediaItemTransition() { .verify(mockListener) .onMediaItemTransition(eq(mediaItem2), eq(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK)); inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + assertThat(firstMediaMetadata).isEqualTo(mediaItem1.mediaMetadata); + assertThat(secondMediaMetadata).isEqualTo(mediaItem2.mediaMetadata); } @Test @@ -1773,6 +1778,108 @@ 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()) + .setTag(1) + .build()); + ImmutableList secondPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setTag(2) + .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()) + .setTag(3) + .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++) { @@ -1792,7 +1899,8 @@ 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").build()) + .setMediaMetadata( + new MediaMetadata.Builder().setArtist("Foo Bar - " + mediaQueueItemId).build()) .setMimeType(MimeTypes.APPLICATION_MPD) .setTag(mediaQueueItemId) .build(); From da52b9489a22f9f40130e4bbd38c7480d42fe530 Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Wed, 13 Jul 2022 17:40:18 +0000 Subject: [PATCH 33/40] Merge pull request #110 from ittiam-systems:rtp_vp8_test PiperOrigin-RevId: 460513413 (cherry picked from commit 9d9bbe3d33721b1301ae16df7f87f9aa51d06b89) --- RELEASENOTES.md | 2 + .../exoplayer/rtsp/reader/RtpVp8Reader.java | 68 ++++-- .../rtsp/reader/RtpVp8ReaderTest.java | 203 ++++++++++++++++++ 3 files changed, 251 insertions(+), 22 deletions(-) create mode 100644 libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/reader/RtpVp8ReaderTest.java diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 3ccb449b8c1..07c362a89a7 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -26,6 +26,8 @@ * 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)). 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); + } +} From 05a1bbf66e36a0f0dc47259ac2c506094031c8ab Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 12 Jul 2022 20:41:57 +0000 Subject: [PATCH 34/40] Don't set the tag in CastTimeline Leaving the media item that has been passed in unchanged, ensures that the media item in the timeline is equal to the media item that the user has passed into the player. The value of the tag is the uid of the window, meaning this is redundant information. #minor-release PiperOrigin-RevId: 460542246 (cherry picked from commit b61a06ba2f1aa4cc75dc1702ad94e483101adc51) --- .../androidx/media3/cast/CastTimeline.java | 2 +- .../androidx/media3/cast/CastPlayerTest.java | 65 +++++++------------ 2 files changed, 25 insertions(+), 42 deletions(-) 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 d21fca26083..7cf90c5a4dc 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastTimeline.java @@ -132,7 +132,7 @@ public CastTimeline(int[] itemIds, SparseArray itemIdToData) { int id = ids[i]; idsToIndex.put(id, i); ItemData data = itemIdToData.get(id, ItemData.EMPTY); - mediaItems[i] = data.mediaItem.buildUpon().setTag(id).build(); + mediaItems[i] = data.mediaItem; durationsUs[i] = data.durationUs; defaultPositionsUs[i] = data.defaultPositionUs == C.TIME_UNSET ? 0 : data.defaultPositionUs; isLive[i] = data.isLive; 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 11bbf97f799..0462878afa5 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -100,6 +100,7 @@ public class CastPlayerTest { private CastPlayer castPlayer; + private DefaultMediaItemConverter mediaItemConverter; private RemoteMediaClient.Callback remoteMediaClientCallback; @Mock private RemoteMediaClient mockRemoteMediaClient; @@ -134,7 +135,8 @@ public void setUp() { 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(); @@ -427,22 +429,13 @@ public void setMediaItems_replaceExistingPlaylist_notifiesMediaItemTransition() String uri1 = "http://www.google.com/video1"; String uri2 = "http://www.google.com/video2"; firstPlaylist.add( - new MediaItem.Builder() - .setUri(uri1) - .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(1) - .build()); + new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); firstPlaylist.add( - new MediaItem.Builder() - .setUri(uri2) - .setMimeType(MimeTypes.APPLICATION_MP4) - .setTag(2) - .build()); + new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); ImmutableList secondPlaylist = ImmutableList.of( new MediaItem.Builder() .setUri(Uri.EMPTY) - .setTag(3) .setMimeType(MimeTypes.APPLICATION_MPD) .build()); @@ -472,23 +465,14 @@ public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity( String uri1 = "http://www.google.com/video1"; String uri2 = "http://www.google.com/video2"; firstPlaylist.add( - new MediaItem.Builder() - .setUri(uri1) - .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(1) - .build()); + new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); firstPlaylist.add( - new MediaItem.Builder() - .setUri(uri2) - .setMimeType(MimeTypes.APPLICATION_MP4) - .setTag(2) - .build()); + new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); ImmutableList secondPlaylist = ImmutableList.of( new MediaItem.Builder() .setUri(Uri.EMPTY) .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(3) .build()); castPlayer.setMediaItems(firstPlaylist, /* startIndex= */ 1, /* startPositionMs= */ 2000L); @@ -556,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 @@ -722,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)); } } @@ -1791,13 +1778,11 @@ public void setMediaItems_doesNotifyOnMetadataChanged() { .setUri(uri1) .setMimeType(MimeTypes.APPLICATION_MPD) .setMediaMetadata(new MediaMetadata.Builder().setArtist("foo").build()) - .setTag(1) .build()); ImmutableList secondPlaylist = ImmutableList.of( new MediaItem.Builder() .setUri(Uri.EMPTY) - .setTag(2) .setMediaMetadata(new MediaMetadata.Builder().setArtist("bar").build()) .setMimeType(MimeTypes.APPLICATION_MPD) .build(), @@ -1805,7 +1790,6 @@ public void setMediaItems_doesNotifyOnMetadataChanged() { .setUri(uri2) .setMimeType(MimeTypes.APPLICATION_MP4) .setMediaMetadata(new MediaMetadata.Builder().setArtist("foobar").build()) - .setTag(3) .build()); castPlayer.addListener(mockListener); @@ -1902,7 +1886,6 @@ private MediaItem createMediaItem(int mediaQueueItemId) { .setMediaMetadata( new MediaMetadata.Builder().setArtist("Foo Bar - " + mediaQueueItemId).build()) .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(mediaQueueItemId) .build(); } From 6180035ecff5362cc77e2a93b9be898597ed8adc Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 12 Jul 2022 20:43:24 +0000 Subject: [PATCH 35/40] Add migration script Note: This was already reviewed in . This doesn't mean we cannot apply further changes though. PiperOrigin-RevId: 460542835 (cherry picked from commit f9a39201aafdb9b660d8c57fb8ffa051a9dc31c7) --- github/media3-migration.sh | 386 +++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 github/media3-migration.sh diff --git a/github/media3-migration.sh b/github/media3-migration.sh new file mode 100644 index 00000000000..f80ac4dfa35 --- /dev/null +++ b/github/media3-migration.sh @@ -0,0 +1,386 @@ +#!/bin/bash +# Copyright (C) 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. +## +shopt -s extglob + +PACKAGE_MAPPINGS='com.google.android.exoplayer2 androidx.media3.exoplayer +com.google.android.exoplayer2.analytics androidx.media3.exoplayer.analytics +com.google.android.exoplayer2.audio androidx.media3.exoplayer.audio +com.google.android.exoplayer2.castdemo androidx.media3.demo.cast +com.google.android.exoplayer2.database androidx.media3.database +com.google.android.exoplayer2.decoder androidx.media3.decoder +com.google.android.exoplayer2.demo androidx.media3.demo.main +com.google.android.exoplayer2.drm androidx.media3.exoplayer.drm +com.google.android.exoplayer2.ext.av1 androidx.media3.decoder.av1 +com.google.android.exoplayer2.ext.cast androidx.media3.cast +com.google.android.exoplayer2.ext.cronet androidx.media3.datasource.cronet +com.google.android.exoplayer2.ext.ffmpeg androidx.media3.decoder.ffmpeg +com.google.android.exoplayer2.ext.flac androidx.media3.decoder.flac +com.google.android.exoplayer2.ext.ima androidx.media3.exoplayer.ima +com.google.android.exoplayer2.ext.leanback androidx.media3.ui.leanback +com.google.android.exoplayer2.ext.okhttp androidx.media3.datasource.okhttp +com.google.android.exoplayer2.ext.opus androidx.media3.decoder.opus +com.google.android.exoplayer2.ext.rtmp androidx.media3.datasource.rtmp +com.google.android.exoplayer2.ext.vp9 androidx.media3.decoder.vp9 +com.google.android.exoplayer2.ext.workmanager androidx.media3.exoplayer.workmanager +com.google.android.exoplayer2.extractor androidx.media3.extractor +com.google.android.exoplayer2.gldemo androidx.media3.demo.gl +com.google.android.exoplayer2.mediacodec androidx.media3.exoplayer.mediacodec +com.google.android.exoplayer2.metadata androidx.media3.extractor.metadata +com.google.android.exoplayer2.offline androidx.media3.exoplayer.offline +com.google.android.exoplayer2.playbacktests androidx.media3.test.exoplayer.playback +com.google.android.exoplayer2.robolectric androidx.media3.test.utils.robolectric +com.google.android.exoplayer2.scheduler androidx.media3.exoplayer.scheduler +com.google.android.exoplayer2.source androidx.media3.exoplayer.source +com.google.android.exoplayer2.source.dash androidx.media3.exoplayer.dash +com.google.android.exoplayer2.source.hls androidx.media3.exoplayer.hls +com.google.android.exoplayer2.source.rtsp androidx.media3.exoplayer.rtsp +com.google.android.exoplayer2.source.smoothstreaming androidx.media3.exoplayer.smoothstreaming +com.google.android.exoplayer2.surfacedemo androidx.media3.demo.surface +com.google.android.exoplayer2.testdata androidx.media3.test.data +com.google.android.exoplayer2.testutil androidx.media3.test.utils +com.google.android.exoplayer2.text androidx.media3.extractor.text +com.google.android.exoplayer2.trackselection androidx.media3.exoplayer.trackselection +com.google.android.exoplayer2.transformer androidx.media3.transformer +com.google.android.exoplayer2.transformerdemo androidx.media3.demo.transformer +com.google.android.exoplayer2.ui androidx.media3.ui +com.google.android.exoplayer2.upstream androidx.media3.datasource +com.google.android.exoplayer2.upstream.cache androidx.media3.datasource.cache +com.google.android.exoplayer2.upstream.crypto androidx.media3.exoplayer.upstream.crypto +com.google.android.exoplayer2.util androidx.media3.common.util +com.google.android.exoplayer2.util androidx.media3.exoplayer.util +com.google.android.exoplayer2.video androidx.media3.exoplayer.video' + + +CLASS_RENAMINGS='com.google.android.exoplayer2.ui.StyledPlayerView androidx.media3.ui.PlayerView +StyledPlayerView PlayerView +com.google.android.exoplayer2.ui.StyledPlayerControlView androidx.media3.ui.PlayerControlView +StyledPlayerControlView PlayerControlView +com.google.android.exoplayer2.ExoPlayerLibraryInfo androidx.media3.common.MediaLibraryInfo +ExoPlayerLibraryInfo MediaLibraryInfo +com.google.android.exoplayer2.SimpleExoPlayer androidx.media3.exoplayer.ExoPlayer +SimpleExoPlayer ExoPlayer' + +CLASS_MAPPINGS='com.google.android.exoplayer2.text.span androidx.media3.common.text HorizontalTextInVerticalContextSpan LanguageFeatureSpan RubySpan SpanUtil TextAnnotation TextEmphasisSpan +com.google.android.exoplayer2.text androidx.media3.common.text CueGroup Cue +com.google.android.exoplayer2.text androidx.media3.exoplayer.text ExoplayerCuesDecoder SubtitleDecoderFactory TextOutput TextRenderer +com.google.android.exoplayer2.upstream.crypto androidx.media3.datasource AesCipherDataSource AesCipherDataSink AesFlushingCipher +com.google.android.exoplayer2.util androidx.media3.common.util AtomicFile Assertions BundleableUtil BundleUtil Clock ClosedSource CodecSpecificDataUtil ColorParser ConditionVariable Consumer CopyOnWriteMultiset EGLSurfaceTexture GlProgram GlUtil HandlerWrapper LibraryLoader ListenerSet Log LongArray MediaFormatUtil NetworkTypeObserver NonNullApi NotificationUtil ParsableBitArray ParsableByteArray RepeatModeUtil RunnableFutureTask SystemClock SystemHandlerWrapper TimedValueQueue TimestampAdjuster TraceUtil UnknownNull UnstableApi UriUtil Util XmlPullParserUtil +com.google.android.exoplayer2.util androidx.media3.common ErrorMessageProvider FlagSet FileTypes MimeTypes PriorityTaskManager +com.google.android.exoplayer2.metadata androidx.media3.common Metadata +com.google.android.exoplayer2.metadata androidx.media3.exoplayer.metadata MetadataDecoderFactory MetadataOutput MetadataRenderer +com.google.android.exoplayer2.audio androidx.media3.common AudioAttributes AuxEffectInfo +com.google.android.exoplayer2.ui androidx.media3.common AdOverlayInfo AdViewProvider +com.google.android.exoplayer2.source.ads androidx.media3.common AdPlaybackState +com.google.android.exoplayer2.source androidx.media3.common MediaPeriodId TrackGroup +com.google.android.exoplayer2.offline androidx.media3.common StreamKey +com.google.android.exoplayer2.ui androidx.media3.exoplayer.offline DownloadNotificationHelper +com.google.android.exoplayer2.trackselection androidx.media3.common TrackSelectionParameters TrackSelectionOverride +com.google.android.exoplayer2.video androidx.media3.common ColorInfo VideoSize +com.google.android.exoplayer2.upstream androidx.media3.common DataReader +com.google.android.exoplayer2.upstream androidx.media3.exoplayer.upstream Allocation Allocator BandwidthMeter CachedRegionTracker DefaultAllocator DefaultBandwidthMeter DefaultLoadErrorHandlingPolicy Loader LoaderErrorThrower ParsingLoadable SlidingPercentile TimeToFirstByteEstimator +com.google.android.exoplayer2.audio androidx.media3.extractor AacUtil Ac3Util Ac4Util DtsUtil MpegAudioUtil OpusUtil WavUtil +com.google.android.exoplayer2.util androidx.media3.extractor NalUnitUtil ParsableNalUnitBitArray +com.google.android.exoplayer2.video androidx.media3.extractor AvcConfig DolbyVisionConfig HevcConfig +com.google.android.exoplayer2.decoder androidx.media3.exoplayer DecoderCounters DecoderReuseEvaluation +com.google.android.exoplayer2.util androidx.media3.exoplayer MediaClock StandaloneMediaClock +com.google.android.exoplayer2 androidx.media3.exoplayer FormatHolder PlayerMessage +com.google.android.exoplayer2 androidx.media3.common BasePlayer BundleListRetriever Bundleable ControlDispatcher C DefaultControlDispatcher DeviceInfo ErrorMessageProvider ExoPlayerLibraryInfo Format ForwardingPlayer HeartRating IllegalSeekPositionException MediaItem MediaMetadata ParserException PercentageRating PlaybackException PlaybackParameters Player PositionInfo Rating StarRating ThumbRating Timeline Tracks +com.google.android.exoplayer2.drm androidx.media3.common DrmInitData' + +DEPENDENCY_MAPPINGS='exoplayer media3-exoplayer +exoplayer-common media3-common +exoplayer-core media3-exoplayer +exoplayer-dash media3-exoplayer-dash +exoplayer-database media3-database +exoplayer-datasource media-datasource +exoplayer-decoder media3-decoder +exoplayer-extractor media3-extractor +exoplayer-hls media3-exoplayer-hls +exoplayer-robolectricutils media3-test-utils-robolectric +exoplayer-rtsp media3-exoplayer-rtsp +exoplayer-smoothstreaming media3-exoplayer-smoothstreaming +exoplayer-testutils media3-test-utils +exoplayer-transformer media3-transformer +exoplayer-ui media3-ui +extension-cast media3-cast +extension-cronet media3-datasource-cronet +extension-ima media3-exoplayer-ima +extension-leanback media3-ui-leanback +extension-okhttp media3-datasource-okhttp +extension-rtmp media3-datasource-rtmp +extension-workmanager media3-exoplayer-workmanager' + +# Rewrites classes, packages and dependencies from the legacy ExoPlayer package structure +# to androidx.media3 structure. + +MEDIA3_VERSION="1.0.0-beta02" +LEGACY_PEER_VERSION="2.18.1" + +function usage() { + echo "usage: $0 [-p|-c|-d|-v]|[-m|-l [-x ] [-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')" From 3abffb75839709e0d9a925f20d608209a03cee0d Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Wed, 13 Jul 2022 17:45:54 +0000 Subject: [PATCH 36/40] Merge pull request #10185 from TiVo:p-custom-logger PiperOrigin-RevId: 460689252 (cherry picked from commit bd8723e35a195e0e67816a64e74266bac9fe5058) --- RELEASENOTES.md | 2 + .../java/androidx/media3/common/util/Log.java | 156 +++++++++++++++--- 2 files changed, 132 insertions(+), 26 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 07c362a89a7..0591be975fb 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -9,6 +9,8 @@ ([#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)). * Extractors: * Add support for AVI ([#2092](https://github.com/google/ExoPlayer/issues/2092)). 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", " "); + } } } From dfe8bee0cf130c7d17f56e714816d6bfbec1051a Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 13 Jul 2022 15:27:55 +0000 Subject: [PATCH 37/40] Fix setDataSourceFactory handling in DefaultMediaSourceFactory The call doesn't currently reset the already loaded suppliers and factories. Also fix the supplier loading code to use a local copy of the current dataSourceFactory to avoid leaking an updated instance to a later invocation. Issue: androidx/media#116 PiperOrigin-RevId: 460721541 (cherry picked from commit adc50515e93e6fdcf303d168e8388050503c46ef) --- RELEASENOTES.md | 3 ++ .../source/DefaultMediaSourceFactory.java | 13 ++--- .../dash/DefaultMediaSourceFactoryTest.java | 54 +++++++++++++++++++ .../hls/DefaultMediaSourceFactoryTest.java | 54 +++++++++++++++++++ .../exoplayer_smoothstreaming/build.gradle | 1 + .../DefaultMediaSourceFactoryTest.java | 54 +++++++++++++++++++ 6 files changed, 173 insertions(+), 6 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0591be975fb..e9d32587825 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -11,6 +11,9 @@ ([#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)). 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_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_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_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; + } + }); + } } From 37f11161ce205d4aec7e804a4c92fecbc7cc67ff Mon Sep 17 00:00:00 2001 From: rohks Date: Fri, 15 Jul 2022 10:15:31 +0000 Subject: [PATCH 38/40] Version bump to exoplayer:2.18.1 and media3:1.0.0-beta02 #minor-release PiperOrigin-RevId: 461162552 (cherry picked from commit be27daebc4e598a4c0d532779649a77f82650911) --- .github/ISSUE_TEMPLATE/bug.yml | 1 + RELEASENOTES.md | 19 +++++++++++++++++++ constants.gradle | 4 ++-- .../media3/common/MediaLibraryInfo.java | 6 +++--- 4 files changed, 25 insertions(+), 5 deletions(-) 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 e9d32587825..5391859a21f 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,25 @@ ### Unreleased changes +* Core library: + * Add `ExoPlayer.isTunnelingEnabled` to check if tunneling is enabled for + the currently selected tracks + ([#2518](https://github.com/google/ExoPlayer/issues/2518)). + * Use `SingleThreadExecutor` for releasing `AudioTrack` instances to avoid + OutOfMemory errors when releasing multiple players at the same time + ([#10057](https://github.com/google/ExoPlayer/issues/10057)). +* Metadata: + * `MetadataRenderer` can now be configured to render metadata as soon as + they are available. Create an instance with + `MetadataRenderer(MetadataOutput, Looper, MetadataDecoderFactory, + boolean)` to specify whether the renderer will output metadata early or + in sync with the player position. + +### 1.0.0-beta02 (2022-07-15) + +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 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/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; From cae428c7a63568d4da9eaf598112112b70dc80b7 Mon Sep 17 00:00:00 2001 From: rohks Date: Thu, 21 Jul 2022 12:34:22 +0000 Subject: [PATCH 39/40] Modify release date for 2.18.1 and media3:1.0.0-beta02 Also rearranged release notes to correctly show when the changes were released. PiperOrigin-RevId: 462361982 (cherry picked from commit 26c8478de667180cb8137a20ae88df285c0bb046) --- RELEASENOTES.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 5391859a21f..20f6474fc77 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -16,7 +16,7 @@ boolean)` to specify whether the renderer will output metadata early or in sync with the player position. -### 1.0.0-beta02 (2022-07-15) +### 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). @@ -34,8 +34,6 @@ This release corresponds to the `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 @@ -48,8 +46,6 @@ This release corresponds to 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: @@ -156,6 +152,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 @@ -205,6 +203,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 From 86e2361c50b9f832d2a363a4e2116e2bec50c74d Mon Sep 17 00:00:00 2001 From: Rohit Singh Date: Thu, 21 Jul 2022 14:42:42 +0000 Subject: [PATCH 40/40] Remove unreleased changes section from RELEASENOTES.md --- RELEASENOTES.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 20f6474fc77..70e9bcdf25e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,21 +1,5 @@ Release notes -### Unreleased changes - -* Core library: - * Add `ExoPlayer.isTunnelingEnabled` to check if tunneling is enabled for - the currently selected tracks - ([#2518](https://github.com/google/ExoPlayer/issues/2518)). - * Use `SingleThreadExecutor` for releasing `AudioTrack` instances to avoid - OutOfMemory errors when releasing multiple players at the same time - ([#10057](https://github.com/google/ExoPlayer/issues/10057)). -* Metadata: - * `MetadataRenderer` can now be configured to render metadata as soon as - they are available. Create an instance with - `MetadataRenderer(MetadataOutput, Looper, MetadataDecoderFactory, - boolean)` to specify whether the renderer will output metadata early or - in sync with the player position. - ### 1.0.0-beta02 (2022-07-21) This release corresponds to the