diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 3851f7b13c..e6dde5ad63 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -20,6 +20,7 @@ body: label: Media3 Version description: What version of Media3 (or ExoPlayer) are you using? options: + - Media3 1.0.1 - Media3 1.0.0 - Media3 1.0.0-rc02 - Media3 1.0.0-rc01 @@ -29,6 +30,7 @@ body: - Media3 1.0.0-alpha03 - Media3 1.0.0-alpha02 - Media3 1.0.0-alpha01 + - ExoPlayer 2.18.6 - ExoPlayer 2.18.5 - ExoPlayer 2.18.4 - ExoPlayer 2.18.3 diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 725b774911..386abe6bf9 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -36,7 +36,7 @@ In case your question is related to a piece of media: - Authentication HTTP headers Don't forget to check ExoPlayer's supported formats and devices, if applicable -(https://exoplayer.dev/supported-formats.html). +(https://developer.android.com/guide/topics/media/exoplayer/supported-formats). If there's something you don't want to post publicly, please submit the issue, then email the link/bug report to dev.exoplayer@gmail.com using a subject in the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87bf328019..5908bd201c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,21 @@ We will also consider high quality pull requests. These should merge into the `main` branch. Before a pull request can be accepted you must submit a Contributor License Agreement, as described below. +### Code style + +We follow the +[Google Java Style Guide](https://google.github.io/styleguide/javaguide.html) +and use [`google-java-format`](https://github.com/google/google-java-format) to +automatically reformat the code. Please consider auto-formatting your changes +before opening a PR (we will otherwise do this ourselves before merging). You +can use the various IDE integrations available, or bulk-reformat all the changes +you made on top of `main` using +[`google-java-format-diff.py`](https://github.com/google/google-java-format/blob/master/scripts/google-java-format-diff.py): + +```shell +$ git diff -U0 main... | google-java-format-diff.py -p1 -i +``` + ## Contributor license agreement Contributions to any Google project must be accompanied by a Contributor diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 535e4e4f1f..acce6295ca 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,44 @@ # Release notes +### 1.0.1 (2023-04-18) + +This release corresponds to the +[ExoPlayer 2.18.6 release](https://github.com/google/ExoPlayer/releases/tag/r2.18.6). + +* Core library: + * Reset target live stream override when seeking to default position + ([#11051](https://github.com/google/ExoPlayer/pull/11051)). + * Fix bug where empty sample streams in the media could cause playback to + be stuck. +* Session: + * Fix bug where multiple identical queue items published by a legacy + `MediaSessionCompat` result in an exception in `MediaController` + ([#290](https://github.com/androidx/media/issues/290)). + * Add missing forwarding of `MediaSession.broadcastCustomCommand` to the + legacy `MediaControllerCompat.Callback.onSessionEvent` + ([#293](https://github.com/androidx/media/issues/293)). + * Fix bug where calling `MediaSession.setPlayer` doesn't update the + available commands. + * Fix issue that `TrackSelectionOverride` instances sent from a + `MediaController` are ignored if they reference a group with + `Format.metadata` + ([#296](https://github.com/androidx/media/issues/296)). + * Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be + available to access metadata via the legacy `MediaSessionCompat`. + * Fix issue where `MediaSession` instances on a background thread cause + crashes when used in `MediaSessionService` + ([#318](https://github.com/androidx/media/issues/318)). + * Fix issue where a media button receiver was declared by the library + without the app having intended this + ([#314](https://github.com/androidx/media/issues/314)). +* DASH: + * Fix handling of empty segment timelines + ([#11014](https://github.com/google/ExoPlayer/issues/11014)). +* RTSP: + * Retry with TCP if RTSP Setup with UDP fails with RTSP Error 461 + UnsupportedTransport + ([#11069](https://github.com/google/ExoPlayer/issues/11069)). + ### 1.0.0 (2023-03-22) This release corresponds to the diff --git a/constants.gradle b/constants.gradle index c07fb57692..dac2a21c37 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' - releaseVersionCode = 1_000_000_3_00 + releaseVersion = '1.0.1' + releaseVersionCode = 1_000_001_3_00 minSdkVersion = 16 appTargetSdkVersion = 33 // API version before restricting local file access. diff --git a/demos/gl/build.gradle b/demos/gl/build.gradle index ff8333a254..8666c45aa0 100644 --- a/demos/gl/build.gradle +++ b/demos/gl/build.gradle @@ -27,6 +27,7 @@ android { versionCode project.ext.releaseVersionCode minSdkVersion project.ext.minSdkVersion targetSdkVersion project.ext.appTargetSdkVersion + multiDexEnabled true } buildTypes { diff --git a/demos/main/src/main/res/values/strings.xml b/demos/main/src/main/res/values/strings.xml index ce9c90d0c2..b8f6e7c320 100644 --- a/demos/main/src/main/res/values/strings.xml +++ b/demos/main/src/main/res/values/strings.xml @@ -21,7 +21,7 @@ Unexpected intent action: %1$s - Cleartext HTTP traffic not permitted. See https://exoplayer.dev/issues/cleartext-not-permitted + Cleartext HTTP traffic not permitted. See https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted Playback failed 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 810a6ac9b7..9328a059e9 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 @@ -26,6 +26,7 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ListView import android.widget.TextView +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem @@ -73,20 +74,24 @@ class MainActivity : AppCompatActivity() { val intent = Intent(this, PlayerActivity::class.java) startActivity(intent) } + + onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(/* enabled= */ true) { + override fun handleOnBackPressed() { + popPathStack() + } + } + ) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { - onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } return super.onOptionsItemSelected(item) } - override fun onBackPressed() { - popPathStack() - } - override fun onStart() { super.onStart() initializeBrowser() diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlayerActivity.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlayerActivity.kt index 9930594857..e4f09b8d33 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlayerActivity.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlayerActivity.kt @@ -135,6 +135,7 @@ class PlayerActivity : AppCompatActivity() { updateMediaMetadataUI(controller.mediaMetadata) updateShuffleSwitchUI(controller.shuffleModeEnabled) updateRepeatSwitchUI(controller.repeatMode) + playerView.setShowSubtitleButton(controller.currentTracks.isTypeSupported(TRACK_TYPE_TEXT)) controller.addListener( object : Player.Listener { diff --git a/demos/transformer/README.md b/demos/transformer/README.md index fd767ba6c8..9733337292 100644 --- a/demos/transformer/README.md +++ b/demos/transformer/README.md @@ -61,6 +61,6 @@ manual steps. (this will only appear if the AAR is present), then build and run the demo app and select a MediaPipe-based effect. -[Transformer]: https://exoplayer.dev/transforming-media.html +[Transformer]: https://developer.android.com/guide/topics/media/transforming-media [MediaPipe]: https://google.github.io/mediapipe/ [build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html diff --git a/demos/transformer/src/main/java/androidx/media3/demo/transformer/MatrixTransformationFactory.java b/demos/transformer/src/main/java/androidx/media3/demo/transformer/MatrixTransformationFactory.java index 0f31a589c9..f4682e249c 100644 --- a/demos/transformer/src/main/java/androidx/media3/demo/transformer/MatrixTransformationFactory.java +++ b/demos/transformer/src/main/java/androidx/media3/demo/transformer/MatrixTransformationFactory.java @@ -28,7 +28,7 @@ import androidx.media3.effect.MatrixTransformation; */ /* package */ final class MatrixTransformationFactory { /** - * Returns a {@link MatrixTransformation} that rescales the frames over the first {@value + * Returns a {@link MatrixTransformation} that rescales the frames over the first {@link * #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases * linearly in size from a single point to filling the full output frame. */ diff --git a/libraries/common/src/main/java/androidx/media3/common/AuxEffectInfo.java b/libraries/common/src/main/java/androidx/media3/common/AuxEffectInfo.java index c51844ed75..a94f1afcaf 100644 --- a/libraries/common/src/main/java/androidx/media3/common/AuxEffectInfo.java +++ b/libraries/common/src/main/java/androidx/media3/common/AuxEffectInfo.java @@ -52,12 +52,12 @@ public final class AuxEffectInfo { * Creates an instance with the given effect identifier and send level. * * @param effectId The effect identifier. This is the value returned by {@link - * AudioEffect#getId()} on the effect, or {@value #NO_AUX_EFFECT_ID} which represents no + * AudioEffect#getId()} on the effect, or {@link #NO_AUX_EFFECT_ID} which represents no * effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying * audio track. * @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1 - * is full send. If {@code effectId} is not {@value #NO_AUX_EFFECT_ID}, this value is passed - * to {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track. + * is full send. If {@code effectId} is not {@link #NO_AUX_EFFECT_ID}, this value is passed to + * {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track. */ public AuxEffectInfo(int effectId, float sendLevel) { this.effectId = effectId; diff --git a/libraries/common/src/main/java/androidx/media3/common/Format.java b/libraries/common/src/main/java/androidx/media3/common/Format.java index b34bead66e..37dd95e79b 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Format.java +++ b/libraries/common/src/main/java/androidx/media3/common/Format.java @@ -35,7 +35,8 @@ import java.util.UUID; * *

When building formats, populate all fields whose values are known and relevant to the type of * format being constructed. For information about different types of format, see ExoPlayer's Supported formats page. + * href="https://developer.android.com/guide/topics/media/exoplayer/supported-formats">Supported + * formats page. * *

Fields commonly relevant to all formats

* diff --git a/libraries/common/src/main/java/androidx/media3/common/ForwardingPlayer.java b/libraries/common/src/main/java/androidx/media3/common/ForwardingPlayer.java index 957ab7d219..0941df7d5e 100644 --- a/libraries/common/src/main/java/androidx/media3/common/ForwardingPlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/ForwardingPlayer.java @@ -20,6 +20,7 @@ import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.TextureView; +import androidx.annotation.CallSuper; import androidx.annotation.Nullable; import androidx.media3.common.text.Cue; import androidx.media3.common.text.CueGroup; @@ -47,14 +48,29 @@ public class ForwardingPlayer implements Player { return player.getApplicationLooper(); } - /** Calls {@link Player#addListener(Listener)} on the delegate. */ + /** + * Calls {@link Player#addListener(Listener)} on the delegate. + * + *

Overrides of this method must delegate to {@code super.addListener} and not {@code + * delegate.addListener}, in order to ensure the correct {@link Player} instance is passed to + * {@link Player.Listener#onEvents(Player, Events)} (i.e. this forwarding instance, and not the + * underlying {@code delegate} instance). + */ @Override + @CallSuper public void addListener(Listener listener) { player.addListener(new ForwardingListener(this, listener)); } - /** Calls {@link Player#removeListener(Listener)} on the delegate. */ + /** + * Calls {@link Player#removeListener(Listener)} on the delegate. + * + *

Overrides of this method must delegate to {@code super.removeListener} and not {@code + * delegate.removeListener}, in order to ensure the listener 'matches' the listener added via + * {@link #addListener} (otherwise the listener registered on the delegate won't be removed). + */ @Override + @CallSuper public void removeListener(Listener listener) { player.removeListener(new ForwardingListener(this, listener)); } 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 f1f2f0a81c..3620406bfb 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"; + public static final String VERSION = "1.0.1"; /** 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"; + public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.1"; /** * 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_3_00; + public static final int VERSION_INT = 1_000_001_3_00; /** Whether the library was compiled with {@link Assertions} checks enabled. */ public static final boolean ASSERTIONS_ENABLED = true; diff --git a/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java b/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java index f9aa597856..d0b93bd157 100644 --- a/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java +++ b/libraries/common/src/main/java/androidx/media3/common/PlaybackException.java @@ -152,8 +152,9 @@ public class PlaybackException extends Exception implements Bundleable { * Caused by the player trying to access cleartext HTTP traffic (meaning http:// rather than * https://) when the app's Network Security Configuration does not permit it. * - *

See this corresponding - * troubleshooting topic. + *

See this + * corresponding troubleshooting topic. */ public static final int ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED = 2007; /** Caused by reading data out of the data bound. */ diff --git a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java index 842f63912b..22757d66bd 100644 --- a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java @@ -3355,7 +3355,8 @@ public abstract class SimpleBasePlayer extends BasePlayer { "Player is accessed on the wrong thread.\n" + "Current thread: '%s'\n" + "Expected thread: '%s'\n" - + "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread", + + "See https://developer.android.com/guide/topics/media/issues/" + + "player-accessed-on-wrong-thread", Thread.currentThread().getName(), applicationLooper.getThread().getName()); throw new IllegalStateException(message); } 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 81459888a1..4fd2474dcd 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Timeline.java +++ b/libraries/common/src/main/java/androidx/media3/common/Timeline.java @@ -61,8 +61,9 @@ import java.util.List; * *

Single media file or on-demand stream

* - *

Example timeline for a
- * single file + *

Example timeline for a single file * *

A timeline for a single media file or on-demand stream consists of a single period and window. * The window spans the whole period, indicating that all parts of the media are available for @@ -71,8 +72,9 @@ import java.util.List; * *

Playlist of media files or on-demand streams

* - *

Example timeline for a
- * playlist of files + *

Example timeline for a playlist of files * *

A timeline for a playlist of media files or on-demand streams consists of multiple periods, * each with its own window. Each window spans the whole of the corresponding period, and typically @@ -82,8 +84,9 @@ import java.util.List; * *

Live stream with limited availability

* - *

Example timeline for
- * a live stream with limited availability + *

Example timeline for a live stream with limited availability * *

A timeline for a live stream consists of a period whose duration is unknown, since it's * continually extending as more content is broadcast. If content only remains available for a @@ -95,8 +98,9 @@ import java.util.List; * *

Live stream with indefinite availability

* - *

Example timeline
- * for a live stream with indefinite availability + *

Example timeline for a live stream with indefinite availability * *

A timeline for a live stream with indefinite availability is similar to the Live stream with limited availability case, except that the window @@ -105,8 +109,9 @@ import java.util.List; * *

Live stream with multiple periods

* - *

Example timeline
- * for a live stream with multiple periods + *

Example timeline for a live stream with multiple periods * *

This case arises when a live stream is explicitly divided into separate periods, for example * at content boundaries. This case is similar to the Live stream with @@ -115,8 +120,9 @@ import java.util.List; * *

On-demand stream followed by live stream

* - *

Example timeline for an
- * on-demand stream followed by a live stream + *

Example timeline for an on-demand stream followed by a live stream * *

This case is the concatenation of the Single media file or on-demand * stream and Live stream with multiple periods cases. When playback @@ -125,12 +131,15 @@ import java.util.List; * *

On-demand stream with mid-roll ads

* - *

Example
- * timeline for an on-demand stream with mid-roll ad groups + *

Example timeline for an on-demand stream with mid-roll ad groups * *

This case includes mid-roll ad groups, which are defined as part of the timeline's single * period. The period can be queried for information about the ad groups and the ads they contain. */ +// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on +// developer.android.com. public abstract class Timeline implements Bundleable { /** diff --git a/libraries/common/src/main/java/androidx/media3/common/Tracks.java b/libraries/common/src/main/java/androidx/media3/common/Tracks.java index 734a8306b8..a932ac6924 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Tracks.java +++ b/libraries/common/src/main/java/androidx/media3/common/Tracks.java @@ -191,6 +191,18 @@ public final class Tracks implements Bundleable { return mediaTrackGroup.type; } + /** + * Copies the {@code Group} with a new {@link TrackGroup#id}. + * + * @param groupId The new {@link TrackGroup#id} + * @return The copied {@code Group}. + */ + @UnstableApi + public Group copyWithId(String groupId) { + return new Group( + mediaTrackGroup.copyWithId(groupId), adaptiveSupported, trackSupport, trackSelected); + } + @Override public boolean equals(@Nullable Object other) { if (this == other) { diff --git a/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java b/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java index 128f708d29..216a01adc1 100644 --- a/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java +++ b/libraries/common/src/main/java/androidx/media3/common/util/GlUtil.java @@ -142,7 +142,7 @@ public final class GlUtil { } /** - * Returns whether creating a GL context with {@value #EXTENSION_PROTECTED_CONTENT} is possible. + * Returns whether creating a GL context with {@link #EXTENSION_PROTECTED_CONTENT} is possible. * *

If {@code true}, the device supports a protected output path for DRM content when using GL. */ @@ -171,7 +171,7 @@ public final class GlUtil { } /** - * Returns whether the {@value #EXTENSION_SURFACELESS_CONTEXT} extension is supported. + * Returns whether the {@link #EXTENSION_SURFACELESS_CONTEXT} extension is supported. * *

This extension allows passing {@link EGL14#EGL_NO_SURFACE} for both the write and read * surfaces in a call to {@link EGL14#eglMakeCurrent(EGLDisplay, EGLSurface, EGLSurface, @@ -187,7 +187,7 @@ public final class GlUtil { } /** - * Returns whether the {@value #EXTENSION_YUV_TARGET} extension is supported. + * Returns whether the {@link #EXTENSION_YUV_TARGET} extension is supported. * *

This extension allows sampling raw YUV values from an external texture, which is required * for HDR. diff --git a/libraries/datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java b/libraries/datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java index cf17e7c070..e83e21b4fd 100644 --- a/libraries/datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java +++ b/libraries/datasource/src/main/java/androidx/media3/datasource/HttpDataSource.java @@ -375,8 +375,8 @@ public interface HttpDataSource extends DataSource { /** * Thrown when cleartext HTTP traffic is not permitted. For more information including how to * enable cleartext traffic, see the corresponding troubleshooting - * topic. + * href="https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted">corresponding + * troubleshooting topic. */ final class CleartextNotPermittedException extends HttpDataSourceException { @@ -384,7 +384,7 @@ public interface HttpDataSource extends DataSource { public CleartextNotPermittedException(IOException cause, DataSpec dataSpec) { super( "Cleartext HTTP traffic not permitted. See" - + " https://exoplayer.dev/issues/cleartext-not-permitted", + + " https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted", cause, dataSpec, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, diff --git a/libraries/decoder_av1/README.md b/libraries/decoder_av1/README.md index bdf5083097..c85350595b 100644 --- a/libraries/decoder_av1/README.md +++ b/libraries/decoder_av1/README.md @@ -128,6 +128,4 @@ GL rendering mode has better performance, so should be preferred * [Troubleshooting using decoding extensions][] - - [Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playbacks diff --git a/libraries/decoder_ffmpeg/README.md b/libraries/decoder_ffmpeg/README.md index 204dd1bba6..5adf97bdcd 100644 --- a/libraries/decoder_ffmpeg/README.md +++ b/libraries/decoder_ffmpeg/README.md @@ -115,12 +115,10 @@ then implement your own logic to use the renderer for a given track. [top level README]: ../../README.md [Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html [ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781 -[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension +[Supported formats]: https://developer.android.com/guide/topics/media/exoplayer/supported-formats#ffmpeg-library ## Links * [Troubleshooting using decoding extensions][] - - [Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback diff --git a/libraries/decoder_flac/README.md b/libraries/decoder_flac/README.md index 3599493410..9db717585a 100644 --- a/libraries/decoder_flac/README.md +++ b/libraries/decoder_flac/README.md @@ -100,6 +100,4 @@ player, then implement your own logic to use the renderer for a given track. * [Troubleshooting using decoding extensions][] - - [Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback diff --git a/libraries/decoder_opus/README.md b/libraries/decoder_opus/README.md index e879f1a87a..dcbfd5b77d 100644 --- a/libraries/decoder_opus/README.md +++ b/libraries/decoder_opus/README.md @@ -104,6 +104,4 @@ player, then implement your own logic to use the renderer for a given track. * [Troubleshooting using decoding extensions][] - - [Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback diff --git a/libraries/decoder_vp9/README.md b/libraries/decoder_vp9/README.md index 633d00a794..1995fe55fd 100644 --- a/libraries/decoder_vp9/README.md +++ b/libraries/decoder_vp9/README.md @@ -141,6 +141,4 @@ GL rendering mode has better performance, so should be preferred. * [Troubleshooting using decoding extensions][] - - [Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java index e58db58847..74e858437d 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java @@ -128,8 +128,9 @@ import java.util.List; * *

The figure below shows ExoPlayer's threading model. * - *

ExoPlayer's
- * threading model + *

ExoPlayer's threading model * *

*/ +// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on +// developer.android.com. public interface ExoPlayer extends Player { /** 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 10c64a7e98..1a830f5c5e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -2673,7 +2673,8 @@ import java.util.concurrent.TimeoutException; "Player is accessed on the wrong thread.\n" + "Current thread: '%s'\n" + "Expected thread: '%s'\n" - + "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread", + + "See https://developer.android.com/guide/topics/media/issues/" + + "player-accessed-on-wrong-thread", Thread.currentThread().getName(), getApplicationLooper().getThread().getName()); if (throwsWhenUsingWrongThread) { throw new IllegalStateException(message); 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 e84ea1e7e0..3f7a467247 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -1239,7 +1239,8 @@ import java.util.concurrent.atomic.AtomicBoolean; /* newPeriodId= */ periodId, /* oldTimeline= */ playbackInfo.timeline, /* oldPeriodId= */ playbackInfo.periodId, - /* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs); + /* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs, + /* forceSetTargetOffsetOverride= */ true); } } finally { playbackInfo = @@ -1882,7 +1883,8 @@ import java.util.concurrent.atomic.AtomicBoolean; /* oldPeriodId= */ playbackInfo.periodId, /* positionForTargetOffsetOverrideUs */ positionUpdate.setTargetLiveOffset ? newPositionUs - : C.TIME_UNSET); + : C.TIME_UNSET, + /* forceSetTargetOffsetOverride= */ false); if (periodPositionChanged || newRequestedContentPositionUs != playbackInfo.requestedContentPositionUs) { Object oldPeriodUid = playbackInfo.periodId.periodUid; @@ -1920,7 +1922,8 @@ import java.util.concurrent.atomic.AtomicBoolean; MediaPeriodId newPeriodId, Timeline oldTimeline, MediaPeriodId oldPeriodId, - long positionForTargetOffsetOverrideUs) + long positionForTargetOffsetOverrideUs, + boolean forceSetTargetOffsetOverride) throws ExoPlaybackException { if (!shouldUseLivePlaybackSpeedControl(newTimeline, newPeriodId)) { // Live playback speed control is unused for the current period, reset speed to user-defined @@ -1950,8 +1953,9 @@ import java.util.concurrent.atomic.AtomicBoolean; int oldWindowIndex = oldTimeline.getPeriodByUid(oldPeriodId.periodUid, period).windowIndex; oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid; } - if (!Util.areEqual(oldWindowUid, windowUid)) { - // Reset overridden target live offset to media values if window changes. + if (!Util.areEqual(oldWindowUid, windowUid) || forceSetTargetOffsetOverride) { + // Reset overridden target live offset to media values if window changes or if seekTo + // default live position. livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET); } } @@ -2074,7 +2078,8 @@ import java.util.concurrent.atomic.AtomicBoolean; /* newPeriodId= */ readingPeriodHolder.info.id, /* oldTimeline= */ playbackInfo.timeline, /* oldPeriodId= */ oldReadingPeriodHolder.info.id, - /* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET); + /* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET, + /* forceSetTargetOffsetOverride= */ false); if (readingPeriodHolder.prepared && readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java index 22274d0860..5dee3f3298 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/Renderer.java @@ -47,9 +47,12 @@ import java.lang.annotation.Target; * valid state transitions are shown below, annotated with the methods that are called during each * transition. * - *

Renderer state
- * transitions + *

Renderer state transitions */ +// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on +// developer.android.com. @UnstableApi public interface Renderer extends PlayerMessage.Target { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioSink.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioSink.java index 20f95192db..b9fc644357 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioSink.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioSink.java @@ -189,6 +189,8 @@ public interface AudioSink { + audioTrackState + " " + ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")") + + " " + + format + (isRecoverable ? " (recoverable)" : ""), audioTrackException); this.audioTrackState = audioTrackState; diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java index ef40d2c4c1..e0f117d222 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/DefaultAudioTrackBufferSizeProvider.java @@ -80,7 +80,7 @@ public class DefaultAudioTrackBufferSizeProvider /** * Sets the minimum length for PCM {@link AudioTrack} buffers, in microseconds. Default is - * {@value #MIN_PCM_BUFFER_DURATION_US}. + * {@link #MIN_PCM_BUFFER_DURATION_US}. */ @CanIgnoreReturnValue public Builder setMinPcmBufferDurationUs(int minPcmBufferDurationUs) { @@ -90,7 +90,7 @@ public class DefaultAudioTrackBufferSizeProvider /** * Sets the maximum length for PCM {@link AudioTrack} buffers, in microseconds. Default is - * {@value #MAX_PCM_BUFFER_DURATION_US}. + * {@link #MAX_PCM_BUFFER_DURATION_US}. */ @CanIgnoreReturnValue public Builder setMaxPcmBufferDurationUs(int maxPcmBufferDurationUs) { @@ -100,7 +100,7 @@ public class DefaultAudioTrackBufferSizeProvider /** * Sets the multiplication factor to apply to the minimum buffer size requested. Default is - * {@value #PCM_BUFFER_MULTIPLICATION_FACTOR}. + * {@link #PCM_BUFFER_MULTIPLICATION_FACTOR}. */ @CanIgnoreReturnValue public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) { @@ -110,7 +110,7 @@ public class DefaultAudioTrackBufferSizeProvider /** * Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is - * {@value #PASSTHROUGH_BUFFER_DURATION_US}. + * {@link #PASSTHROUGH_BUFFER_DURATION_US}. */ @CanIgnoreReturnValue public Builder setPassthroughBufferDurationUs(int passthroughBufferDurationUs) { @@ -119,7 +119,7 @@ public class DefaultAudioTrackBufferSizeProvider } /** - * The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@value + * The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@link * #OFFLOAD_BUFFER_DURATION_US}. */ @CanIgnoreReturnValue @@ -130,7 +130,7 @@ public class DefaultAudioTrackBufferSizeProvider /** * Sets the multiplication factor to apply to the passthrough buffer for AC3 to avoid underruns - * on some devices (e.g., Broadcom 7271). Default is {@value #AC3_BUFFER_MULTIPLICATION_FACTOR}. + * on some devices (e.g., Broadcom 7271). Default is {@link #AC3_BUFFER_MULTIPLICATION_FACTOR}. */ @CanIgnoreReturnValue public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/MediaCodecAudioRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/MediaCodecAudioRenderer.java index 2f63b80cf8..5f02f40c12 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/MediaCodecAudioRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/MediaCodecAudioRenderer.java @@ -105,6 +105,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media private int codecMaxInputSize; private boolean codecNeedsDiscardChannelsWorkaround; + @Nullable private Format inputFormat; /** Codec used for DRM decryption only in passthrough and offload. */ @Nullable private Format decryptOnlyCodecFormat; @@ -500,8 +501,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Nullable protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + inputFormat = checkNotNull(formatHolder.format); @Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder); - eventDispatcher.inputFormatChanged(formatHolder.format, evaluation); + eventDispatcher.inputFormatChanged(inputFormat, evaluation); return evaluation; } @@ -604,6 +606,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media @Override protected void onDisabled() { audioSinkNeedsReset = true; + inputFormat = null; try { audioSink.flush(); } finally { @@ -711,7 +714,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount); } catch (InitializationException e) { throw createRendererException( - e, e.format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED); + e, inputFormat, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED); } catch (WriteException e) { throw createRendererException( e, format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java index c64eb57dab..b04c5b1cd8 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSession.java @@ -136,9 +136,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final PlayerId playerId; - /* package */ final MediaDrmCallback callback; - /* package */ final UUID uuid; - /* package */ final ResponseHandler responseHandler; + private final MediaDrmCallback callback; + private final UUID uuid; + private final Looper playbackLooper; + private final ResponseHandler responseHandler; private @DrmSession.State int state; private int referenceCount; @@ -209,10 +210,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.playerId = playerId; state = STATE_OPENING; + this.playbackLooper = playbackLooper; responseHandler = new ResponseHandler(playbackLooper); } public boolean hasSessionId(byte[] sessionId) { + verifyPlaybackThread(); return Arrays.equals(this.sessionId, sessionId); } @@ -255,50 +258,59 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public final @DrmSession.State int getState() { + verifyPlaybackThread(); return state; } @Override public boolean playClearSamplesWithoutKeys() { + verifyPlaybackThread(); return playClearSamplesWithoutKeys; } @Override @Nullable public final DrmSessionException getError() { + verifyPlaybackThread(); return state == STATE_ERROR ? lastException : null; } @Override public final UUID getSchemeUuid() { + verifyPlaybackThread(); return uuid; } @Override @Nullable public final CryptoConfig getCryptoConfig() { + verifyPlaybackThread(); return cryptoConfig; } @Override @Nullable public Map queryKeyStatus() { + verifyPlaybackThread(); return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId); } @Override @Nullable public byte[] getOfflineLicenseKeySetId() { + verifyPlaybackThread(); return offlineLicenseKeySetId; } @Override public boolean requiresSecureDecoder(String mimeType) { + verifyPlaybackThread(); return mediaDrm.requiresSecureDecoder(checkStateNotNull(sessionId), mimeType); } @Override public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { + verifyPlaybackThread(); if (referenceCount < 0) { Log.e(TAG, "Session reference count less than zero: " + referenceCount); referenceCount = 0; @@ -326,6 +338,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Override public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { + verifyPlaybackThread(); if (referenceCount <= 0) { Log.e(TAG, "release() called on a session that's already fully released."); return; @@ -561,6 +574,18 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } + private void verifyPlaybackThread() { + if (Thread.currentThread() != playbackLooper.getThread()) { + Log.w( + TAG, + "DefaultDrmSession accessed on the wrong thread.\nCurrent thread: " + + Thread.currentThread().getName() + + "\nExpected thread: " + + playbackLooper.getThread().getName(), + new IllegalStateException()); + } + } + // Internal classes. @SuppressLint("HandlerLeak") diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManager.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManager.java index 9a345ffff7..05ab52a039 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManager.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/DefaultDrmSessionManager.java @@ -471,6 +471,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public final void prepare() { + verifyPlaybackThread(/* allowBeforeSetPlayer= */ true); if (prepareCallsCount++ != 0) { return; } @@ -487,6 +488,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public final void release() { + verifyPlaybackThread(/* allowBeforeSetPlayer= */ true); if (--prepareCallsCount != 0) { return; } @@ -513,6 +515,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public DrmSessionReference preacquireSession( @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) { + // Don't verify the playback thread, preacquireSession can be called from any thread. checkState(prepareCallsCount > 0); checkStateNotNull(playbackLooper); PreacquiredSessionReference preacquiredSessionReference = @@ -525,6 +528,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Nullable public DrmSession acquireSession( @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) { + verifyPlaybackThread(/* allowBeforeSetPlayer= */ false); checkState(prepareCallsCount > 0); checkStateNotNull(playbackLooper); return acquireSession( @@ -599,6 +603,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { @Override public @C.CryptoType int getCryptoType(Format format) { + verifyPlaybackThread(/* allowBeforeSetPlayer= */ false); @C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType(); if (format.drmInitData == null) { int trackType = MimeTypes.getTrackType(format.sampleMimeType); @@ -817,6 +822,23 @@ public class DefaultDrmSessionManager implements DrmSessionManager { } } + private void verifyPlaybackThread(boolean allowBeforeSetPlayer) { + if (allowBeforeSetPlayer && playbackLooper == null) { + Log.w( + TAG, + "DefaultDrmSessionManager accessed before setPlayer(), possibly on the wrong thread.", + new IllegalStateException()); + } else if (Thread.currentThread() != checkNotNull(playbackLooper).getThread()) { + Log.w( + TAG, + "DefaultDrmSessionManager accessed on the wrong thread.\nCurrent thread: " + + Thread.currentThread().getName() + + "\nExpected thread: " + + playbackLooper.getThread().getName(), + new IllegalStateException()); + } + } + /** * Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}. * diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java index ab7d4745d1..1dff7d6728 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/drm/OfflineLicenseHelper.java @@ -19,6 +19,7 @@ import android.media.MediaDrm; import android.os.ConditionVariable; import android.os.Handler; import android.os.HandlerThread; +import android.os.Looper; import android.util.Pair; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -31,8 +32,11 @@ import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.drm.DefaultDrmSessionManager.Mode; import androidx.media3.exoplayer.drm.DrmSession.DrmSessionException; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; +import com.google.common.util.concurrent.SettableFuture; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutionException; +import org.checkerframework.checker.nullness.compatqual.NullableType; /** Helper class to download, renew and release offline licenses. */ @RequiresApi(18) @@ -42,9 +46,10 @@ public final class OfflineLicenseHelper { private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA = new Format.Builder().setDrmInitData(new DrmInitData()).build(); - private final ConditionVariable conditionVariable; + private final ConditionVariable drmListenerConditionVariable; private final DefaultDrmSessionManager drmSessionManager; private final HandlerThread handlerThread; + private final Handler handler; private final DrmSessionEventListener.EventDispatcher eventDispatcher; /** @@ -156,28 +161,29 @@ public final class OfflineLicenseHelper { this.eventDispatcher = eventDispatcher; handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper"); handlerThread.start(); - conditionVariable = new ConditionVariable(); + handler = new Handler(handlerThread.getLooper()); + drmListenerConditionVariable = new ConditionVariable(); DrmSessionEventListener eventListener = new DrmSessionEventListener() { @Override public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { - conditionVariable.open(); + drmListenerConditionVariable.open(); } @Override public void onDrmSessionManagerError( int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) { - conditionVariable.open(); + drmListenerConditionVariable.open(); } @Override public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { - conditionVariable.open(); + drmListenerConditionVariable.open(); } @Override public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { - conditionVariable.open(); + drmListenerConditionVariable.open(); } }; eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener); @@ -193,7 +199,8 @@ public final class OfflineLicenseHelper { */ public synchronized byte[] downloadLicense(Format format) throws DrmSessionException { Assertions.checkArgument(format.drmInitData != null); - return blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, format); + return acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread( + DefaultDrmSessionManager.MODE_DOWNLOAD, /* offlineLicenseKeySetId= */ null, format); } /** @@ -206,7 +213,7 @@ public final class OfflineLicenseHelper { public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId) throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); - return blockingKeyRequest( + return acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread( DefaultDrmSessionManager.MODE_DOWNLOAD, offlineLicenseKeySetId, FORMAT_WITH_EMPTY_DRM_INIT_DATA); @@ -221,7 +228,7 @@ public final class OfflineLicenseHelper { public synchronized void releaseLicense(byte[] offlineLicenseKeySetId) throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); - blockingKeyRequest( + acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread( DefaultDrmSessionManager.MODE_RELEASE, offlineLicenseKeySetId, FORMAT_WITH_EMPTY_DRM_INIT_DATA); @@ -237,25 +244,39 @@ public final class OfflineLicenseHelper { public synchronized Pair getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId) throws DrmSessionException { Assertions.checkNotNull(offlineLicenseKeySetId); - drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET); - drmSessionManager.prepare(); - DrmSession drmSession = - openBlockingKeyRequest( - DefaultDrmSessionManager.MODE_QUERY, - offlineLicenseKeySetId, - FORMAT_WITH_EMPTY_DRM_INIT_DATA); - DrmSessionException error = drmSession.getError(); - Pair licenseDurationRemainingSec = - WidevineUtil.getLicenseDurationRemainingSec(drmSession); - drmSession.release(eventDispatcher); - drmSessionManager.release(); - if (error != null) { - if (error.getCause() instanceof KeysExpiredException) { + DrmSession drmSession; + try { + drmSession = + acquireFirstSessionOnHandlerThread( + DefaultDrmSessionManager.MODE_QUERY, + offlineLicenseKeySetId, + FORMAT_WITH_EMPTY_DRM_INIT_DATA); + } catch (DrmSessionException e) { + if (e.getCause() instanceof KeysExpiredException) { return Pair.create(0L, 0L); } - throw error; + throw e; + } + + SettableFuture> licenseDurationRemainingSec = SettableFuture.create(); + handler.post( + () -> { + try { + licenseDurationRemainingSec.set( + Assertions.checkNotNull(WidevineUtil.getLicenseDurationRemainingSec(drmSession))); + } catch (Throwable e) { + licenseDurationRemainingSec.setException(e); + } finally { + drmSession.release(eventDispatcher); + } + }); + try { + return licenseDurationRemainingSec.get(); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); + } finally { + releaseManagerOnHandlerThread(); } - return Assertions.checkNotNull(licenseDurationRemainingSec); } /** Releases the helper. Should be called when the helper is no longer required. */ @@ -263,30 +284,146 @@ public final class OfflineLicenseHelper { handlerThread.quit(); } - private byte[] blockingKeyRequest( + /** + * Returns the result of {@link DrmSession#getOfflineLicenseKeySetId()}, or throws {@link + * NullPointerException} if it's null. + * + *

This method takes care of acquiring and releasing the {@link DrmSessionManager} and {@link + * DrmSession} instances needed. + */ + private byte[] acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread( @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) throws DrmSessionException { - drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET); - drmSessionManager.prepare(); - DrmSession drmSession = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, format); - DrmSessionException error = drmSession.getError(); - byte[] keySetId = drmSession.getOfflineLicenseKeySetId(); - drmSession.release(eventDispatcher); - drmSessionManager.release(); - if (error != null) { - throw error; + DrmSession drmSession = + acquireFirstSessionOnHandlerThread(licenseMode, offlineLicenseKeySetId, format); + + SettableFuture keySetId = SettableFuture.create(); + handler.post( + () -> { + try { + keySetId.set(drmSession.getOfflineLicenseKeySetId()); + } catch (Throwable e) { + keySetId.setException(e); + } finally { + drmSession.release(eventDispatcher); + } + }); + + try { + return Assertions.checkNotNull(keySetId.get()); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); + } finally { + releaseManagerOnHandlerThread(); } - return Assertions.checkNotNull(keySetId); } - private DrmSession openBlockingKeyRequest( - @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) { + /** + * Calls {@link DrmSessionManager#acquireSession(DrmSessionEventListener.EventDispatcher, Format)} + * on {@link #handlerThread} and blocks until a callback is received via {@link + * DrmSessionEventListener}. + * + *

If key loading failed and {@link DrmSession#getState()} returns {@link + * DrmSession#STATE_ERROR} then this method releases the session and throws {@link + * DrmSession#getError()}. + * + *

Callers are responsible for the following: + * + *

+ */ + private DrmSession acquireFirstSessionOnHandlerThread( + @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) + throws DrmSessionException { Assertions.checkNotNull(format.drmInitData); - drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); - conditionVariable.close(); - DrmSession drmSession = drmSessionManager.acquireSession(eventDispatcher, format); - // Block current thread until key loading is finished - conditionVariable.block(); - return Assertions.checkNotNull(drmSession); + SettableFuture drmSessionFuture = SettableFuture.create(); + drmListenerConditionVariable.close(); + handler.post( + () -> { + try { + drmSessionManager.setPlayer(Assertions.checkNotNull(Looper.myLooper()), PlayerId.UNSET); + drmSessionManager.prepare(); + try { + drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); + drmSessionFuture.set( + Assertions.checkNotNull( + drmSessionManager.acquireSession(eventDispatcher, format))); + } catch (Throwable e) { + drmSessionManager.release(); + throw e; + } + } catch (Throwable e) { + drmSessionFuture.setException(e); + } + }); + + DrmSession drmSession; + try { + drmSession = drmSessionFuture.get(); + } catch (ExecutionException | InterruptedException e) { + throw new IllegalStateException(e); + } + + // drmListenerConditionVariable will be opened by a callback to this.eventDispatcher when key + // loading is complete (drmSession.state == STATE_OPENED_WITH_KEYS) or has failed + // (drmSession.state == STATE_ERROR). + drmListenerConditionVariable.block(); + + SettableFuture<@NullableType DrmSessionException> drmSessionErrorFuture = + SettableFuture.create(); + handler.post( + () -> { + try { + DrmSessionException drmSessionError = drmSession.getError(); + if (drmSession.getState() == DrmSession.STATE_ERROR) { + drmSession.release(eventDispatcher); + drmSessionManager.release(); + } + drmSessionErrorFuture.set(drmSessionError); + } catch (Throwable e) { + drmSessionErrorFuture.setException(e); + drmSession.release(eventDispatcher); + drmSessionManager.release(); + } + }); + try { + DrmSessionException drmSessionError = drmSessionErrorFuture.get(); + if (drmSessionError != null) { + throw drmSessionError; + } else { + return drmSession; + } + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + + /** + * Calls {@link DrmSessionManager#release()} on {@link #handlerThread} and blocks until it's + * complete. + */ + private void releaseManagerOnHandlerThread() { + SettableFuture result = SettableFuture.create(); + handler.post( + () -> { + try { + drmSessionManager.release(); + result.set(null); + } catch (Throwable e) { + result.setException(e); + } + }); + try { + result.get(); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } } } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java index 1263bad907..fddfea4e2a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/mediacodec/MediaCodecRenderer.java @@ -642,14 +642,22 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @Override protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) throws ExoPlaybackException { - if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET - || (pendingOutputStreamChanges.isEmpty() - && lastProcessedOutputBufferTimeUs != C.TIME_UNSET - && lastProcessedOutputBufferTimeUs >= largestQueuedPresentationTimeUs)) { - // This is the first stream, or the previous has been fully output already. + if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET) { + // This is the first stream. setOutputStreamInfo( new OutputStreamInfo( /* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs)); + } else if (pendingOutputStreamChanges.isEmpty() + && (largestQueuedPresentationTimeUs == C.TIME_UNSET + || (lastProcessedOutputBufferTimeUs != C.TIME_UNSET + && lastProcessedOutputBufferTimeUs >= largestQueuedPresentationTimeUs))) { + // All previous streams have never queued any samples or have been fully output already. + setOutputStreamInfo( + new OutputStreamInfo( + /* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs)); + if (outputStreamInfo.streamOffsetUs != C.TIME_UNSET) { + onProcessedStreamChange(); + } } else { pendingOutputStreamChanges.add( new OutputStreamInfo(largestQueuedPresentationTimeUs, startPositionUs, offsetUs)); @@ -1581,7 +1589,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer { @CallSuper protected void onProcessedOutputBuffer(long presentationTimeUs) { lastProcessedOutputBufferTimeUs = presentationTimeUs; - if (!pendingOutputStreamChanges.isEmpty() + while (!pendingOutputStreamChanges.isEmpty() && presentationTimeUs >= pendingOutputStreamChanges.peek().previousStreamLastBufferTimeUs) { setOutputStreamInfo(pendingOutputStreamChanges.poll()); onProcessedStreamChange(); 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 64f849448d..4fd667eb26 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 @@ -71,17 +71,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; *
  • {@code DashMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * ends in '.mpd' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is * explicitly set to {@link MimeTypes#APPLICATION_MPD} (Requires the exoplayer-dash module - * to be added to the app). + * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-dash + * module to be added to the app). *
  • {@code HlsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * ends in '.m3u8' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is * explicitly set to {@link MimeTypes#APPLICATION_M3U8} (Requires the exoplayer-hls module to - * be added to the app). + * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-hls + * module to be added to the app). *
  • {@code SsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * ends in '.ism', '.ism/Manifest' or if its {@link MediaItem.LocalConfiguration#mimeType * mimeType field} is explicitly set to {@link MimeTypes#APPLICATION_SS} (Requires the + * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules"> * exoplayer-smoothstreaming module to be added to the app). *
  • {@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link * MediaItem.LocalConfiguration#uri uri} doesn't match one of the above. It tries to infer the diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecRendererTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecRendererTest.java index ac86669ee5..b27a3d3106 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecRendererTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/mediacodec/MediaCodecRendererTest.java @@ -214,6 +214,115 @@ public class MediaCodecRendererTest { inOrder.verify(renderer).onProcessedOutputBuffer(600); } + @Test + public void + render_withReplaceStreamAfterInitialEmptySampleStream_triggersOutputCallbacksInCorrectOrder() + throws Exception { + Format format1 = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build(); + Format format2 = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build(); + FakeSampleStream fakeSampleStream1 = createFakeSampleStream(format1 /* no samples */); + FakeSampleStream fakeSampleStream2 = + createFakeSampleStream(format2, /* sampleTimesUs...= */ 0, 100, 200); + MediaCodecRenderer renderer = spy(new TestRenderer()); + renderer.init(/* index= */ 0, PlayerId.UNSET); + + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {format1}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs= */ 0); + renderer.start(); + long positionUs = 0; + while (!renderer.hasReadStreamToEnd()) { + renderer.render(positionUs, SystemClock.elapsedRealtime()); + positionUs += 100; + } + renderer.replaceStream( + new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 0, /* offsetUs= */ 0); + renderer.setCurrentStreamFinal(); + while (!renderer.isEnded()) { + renderer.render(positionUs, SystemClock.elapsedRealtime()); + positionUs += 100; + } + + InOrder inOrder = inOrder(renderer); + inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0); + inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0); + inOrder.verify(renderer).onProcessedStreamChange(); + inOrder.verify(renderer).onOutputFormatChanged(eq(format2), any()); + inOrder.verify(renderer).onProcessedOutputBuffer(0); + inOrder.verify(renderer).onProcessedOutputBuffer(100); + inOrder.verify(renderer).onProcessedOutputBuffer(200); + } + + @Test + public void + render_withReplaceStreamAfterIntermittentEmptySampleStream_triggersOutputCallbacksInCorrectOrder() + throws Exception { + Format format1 = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build(); + Format format2 = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build(); + Format format3 = + new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(2000).build(); + FakeSampleStream fakeSampleStream1 = + createFakeSampleStream(format1, /* sampleTimesUs...= */ 0, 100); + FakeSampleStream fakeSampleStream2 = createFakeSampleStream(format2 /* no samples */); + FakeSampleStream fakeSampleStream3 = + createFakeSampleStream(format3, /* sampleTimesUs...= */ 0, 100, 200); + MediaCodecRenderer renderer = spy(new TestRenderer()); + renderer.init(/* index= */ 0, PlayerId.UNSET); + + renderer.enable( + RendererConfiguration.DEFAULT, + new Format[] {format1}, + fakeSampleStream1, + /* positionUs= */ 0, + /* joining= */ false, + /* mayRenderStartOfStream= */ true, + /* startPositionUs= */ 0, + /* offsetUs= */ 0); + renderer.start(); + long positionUs = 0; + while (!renderer.hasReadStreamToEnd()) { + renderer.render(positionUs, SystemClock.elapsedRealtime()); + positionUs += 100; + } + renderer.replaceStream( + new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 200, /* offsetUs= */ 200); + while (!renderer.hasReadStreamToEnd()) { + renderer.render(positionUs, SystemClock.elapsedRealtime()); + positionUs += 100; + } + renderer.replaceStream( + new Format[] {format3}, fakeSampleStream3, /* startPositionUs= */ 200, /* offsetUs= */ 200); + renderer.setCurrentStreamFinal(); + while (!renderer.isEnded()) { + renderer.render(positionUs, SystemClock.elapsedRealtime()); + positionUs += 100; + } + + InOrder inOrder = inOrder(renderer); + inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0); + inOrder.verify(renderer).onOutputFormatChanged(eq(format1), any()); + inOrder.verify(renderer).onProcessedOutputBuffer(0); + inOrder.verify(renderer).onProcessedOutputBuffer(100); + inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200); + inOrder.verify(renderer).onProcessedStreamChange(); + inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200); + inOrder.verify(renderer).onProcessedStreamChange(); + inOrder.verify(renderer).onOutputFormatChanged(eq(format3), any()); + inOrder.verify(renderer).onProcessedOutputBuffer(200); + inOrder.verify(renderer).onProcessedOutputBuffer(300); + inOrder.verify(renderer).onProcessedOutputBuffer(400); + } + private FakeSampleStream createFakeSampleStream(Format format, long... sampleTimesUs) { ImmutableList.Builder sampleListBuilder = ImmutableList.builder(); diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java index 31fe7ac8dc..d1c598e5ac 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java @@ -236,9 +236,12 @@ public class DefaultDashChunkSource implements DashChunkSource { // Segments are aligned across representations, so any segment index will do. for (RepresentationHolder representationHolder : representationHolders) { if (representationHolder.segmentIndex != null) { + long segmentCount = representationHolder.getSegmentCount(); + if (segmentCount == 0) { + continue; + } long segmentNum = representationHolder.getSegmentNum(positionUs); long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum); - long segmentCount = representationHolder.getSegmentCount(); long secondSyncUs = firstSyncUs < positionUs && (segmentCount == DashSegmentIndex.INDEX_UNBOUNDED @@ -594,7 +597,7 @@ public class DefaultDashChunkSource implements DashChunkSource { } private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositionUs) { - if (!manifest.dynamic) { + if (!manifest.dynamic || representationHolders[0].getSegmentCount() == 0) { return C.TIME_UNSET; } long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs); diff --git a/libraries/exoplayer_ima/README.md b/libraries/exoplayer_ima/README.md index f7d9750339..a264cde5cf 100644 --- a/libraries/exoplayer_ima/README.md +++ b/libraries/exoplayer_ima/README.md @@ -26,7 +26,7 @@ locally. Instructions for doing this can be found in the [top level README][]. ## Using the module To use the module, follow the instructions on the -[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support) +[Ad insertion page](https://developer.android.com/guide/topics/media/exoplayer/ad-insertion#declarative-ad-support) of the developer guide. The `AdsLoaderProvider` passed to the player's `DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA module only supports players that are accessed on the application's main thread. diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaAdsLoader.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaAdsLoader.java index f90654a290..5ce9df28ca 100644 --- a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaAdsLoader.java +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaAdsLoader.java @@ -273,7 +273,7 @@ public final class ImaAdsLoader implements AdsLoader { /** * Sets the duration in milliseconds for which the player must buffer while preloading an ad * group before that ad group is skipped and marked as having failed to load. Pass {@link - * C#TIME_UNSET} if there should be no such timeout. The default value is {@value + * C#TIME_UNSET} if there should be no such timeout. The default value is {@link * #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms. * *

    The purpose of this timeout is to avoid playback getting stuck in the unexpected case that diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java index c8ac907a8d..751731fce6 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java @@ -49,6 +49,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo; import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException; +import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspUdpUnsupportedTransportException; import androidx.media3.exoplayer.rtsp.RtspMessageChannel.InterleavedBinaryDataListener; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader; @@ -577,8 +578,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; receivedAuthorizationRequest = true; return; } - // fall through: if unauthorized and no userInfo present, or previous authentication - // unsuccessful. + // if unauthorized and no userInfo present, or previous authentication + // unsuccessful, then dispatch RtspPlaybackException + dispatchRtspError( + new RtspPlaybackException( + RtspMessageUtil.toMethodString(requestMethod) + " " + response.status)); + return; + case 461: + String exceptionMessage = + RtspMessageUtil.toMethodString(requestMethod) + " " + response.status; + // If request was SETUP with UDP transport protocol, then throw + // RtspUdpUnsupportedTransportException. + String transportHeaderValue = + checkNotNull(matchingRequest.headers.get(RtspHeaders.TRANSPORT)); + dispatchRtspError( + requestMethod == METHOD_SETUP && !transportHeaderValue.contains("TCP") + ? new RtspUdpUnsupportedTransportException(exceptionMessage) + : new RtspPlaybackException(exceptionMessage)); + return; default: dispatchRtspError( new RtspPlaybackException( diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java index 9dd40cba7c..1af0f2b415 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java @@ -518,7 +518,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // using TCP. Retrying will setup new loadables, so will not retry with the current // loadables. retryWithRtpTcp(); - isUsingRtpTcp = true; } return; } @@ -644,7 +643,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Override public void onPlaybackError(RtspPlaybackException error) { - playbackException = error; + if (error instanceof RtspMediaSource.RtspUdpUnsupportedTransportException && !isUsingRtpTcp) { + // Retry playback with TCP if we receive RtspUdpUnsupportedTransportException, and we are + // not already using TCP. Retrying will setup new loadables. + retryWithRtpTcp(); + } else { + playbackException = error; + } } @Override @@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } private void retryWithRtpTcp() { + // Retry should only run once. + isUsingRtpTcp = true; + rtspClient.retryWithRtpTcp(); @Nullable diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java index f2f6b52e61..0c69fe0df9 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java @@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource { } /** Thrown when an exception or error is encountered during loading an RTSP stream. */ - public static final class RtspPlaybackException extends IOException { + public static class RtspPlaybackException extends IOException { public RtspPlaybackException(String message) { super(message); } @@ -206,6 +206,13 @@ public final class RtspMediaSource extends BaseMediaSource { } } + /** Thrown when an RTSP Unsupported Transport error (461) is encountered during RTSP Setup. */ + public static final class RtspUdpUnsupportedTransportException extends RtspPlaybackException { + public RtspUdpUnsupportedTransportException(String message) { + super(message); + } + } + private final MediaItem mediaItem; private final RtpDataChannel.Factory rtpDataChannelFactory; private final String userAgent; diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java index 104f6ae9c3..e699de2ea7 100644 --- a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java @@ -453,4 +453,77 @@ public final class RtspClientTest { RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get); assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED); } + + @Test + public void connectServerAndClient_describeResponseRequiresAuthentication_doesNotUpdateTimeline() + throws Exception { + class ResponseProvider implements RtspServer.ResponseProvider { + @Override + public RtspResponse getOptionsResponse() { + return new RtspResponse( + /* status= */ 200, + new RtspHeaders.Builder().add(RtspHeaders.PUBLIC, "OPTIONS, DESCRIBE").build()); + } + + @Override + public RtspResponse getDescribeResponse(Uri requestedUri, RtspHeaders headers) { + String authorizationHeader = headers.get(RtspHeaders.AUTHORIZATION); + if (authorizationHeader == null) { + return new RtspResponse( + /* status= */ 401, + new RtspHeaders.Builder() + .add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ)) + .add( + RtspHeaders.WWW_AUTHENTICATE, + "Digest realm=\"RTSP server\"," + + " nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\"," + + " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"") + .add(RtspHeaders.WWW_AUTHENTICATE, "BASIC realm=\"WallyWorld\"") + .build()); + } + if (!authorizationHeader.contains("Digest")) { + return new RtspResponse( + 401, + new RtspHeaders.Builder() + .add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ)) + .build()); + } + + return RtspTestUtils.newDescribeResponseWithSdpMessage( + "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n" + + "s=Exoplayer test\r\n" + + "t=0 0\r\n" + // The session is 50.46s long. + + "a=range:npt=0-50.46\r\n", + rtpPacketStreamDumps, + requestedUri); + } + } + rtspServer = new RtspServer(new ResponseProvider()); + + AtomicBoolean timelineRequestFailed = new AtomicBoolean(); + rtspClient = + new RtspClient( + new SessionInfoListener() { + @Override + public void onSessionTimelineUpdated( + RtspSessionTiming timing, ImmutableList tracks) {} + + @Override + public void onSessionTimelineRequestFailed( + String message, @Nullable Throwable cause) { + timelineRequestFailed.set(true); + } + }, + EMPTY_PLAYBACK_LISTENER, + /* userAgent= */ "ExoPlayer:RtspClientTest", + RtspTestUtils.getTestUri(rtspServer.startAndGetPortNumber()), + SocketFactory.getDefault(), + /* debugLoggingEnabled= */ false); + rtspClient.start(); + + RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get); + assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED); + } } diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java index dc44ce154c..37650c297b 100644 --- a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.rtsp; +import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; import static com.google.common.truth.Truth.assertThat; import static java.lang.Math.min; @@ -42,11 +43,13 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; import javax.net.SocketFactory; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -58,30 +61,20 @@ import org.robolectric.annotation.Config; @RunWith(AndroidJUnit4.class) public final class RtspPlaybackTest { + private static final long DEFAULT_TIMEOUT_MS = 8000; private static final String SESSION_DESCRIPTION = "v=0\r\n" + "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n" + "s=Exoplayer test\r\n" + "t=0 0\r\n"; - private final Context applicationContext; - private final CapturingRenderersFactory capturingRenderersFactory; - private final Clock clock; - private final FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel; - private final RtpDataChannel.Factory rtpDataChannelFactory; - + private Context applicationContext; + private CapturingRenderersFactory capturingRenderersFactory; + private Clock clock; private RtpPacketStreamDump aacRtpPacketStreamDump; // ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment. private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump; - - /** Creates a new instance. */ - public RtspPlaybackTest() { - applicationContext = ApplicationProvider.getApplicationContext(); - capturingRenderersFactory = new CapturingRenderersFactory(applicationContext); - clock = new FakeClock(/* isAutoAdvancing= */ true); - fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); - rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; - } + private RtspServer rtspServer; @Rule public ShadowMediaCodecConfig mediaCodecConfig = @@ -89,61 +82,162 @@ public final class RtspPlaybackTest { @Before public void setUp() throws Exception { + applicationContext = ApplicationProvider.getApplicationContext(); + capturingRenderersFactory = new CapturingRenderersFactory(applicationContext); + clock = new FakeClock(/* isAutoAdvancing= */ true); aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json"); mpeg2tsRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json"); } + @After + public void tearDown() { + Util.closeQuietly(rtspServer); + } + @Test public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception { + FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; ResponseProvider responseProvider = new ResponseProvider( clock, ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel); + rtspServer = new RtspServer(responseProvider); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); - try (RtspServer rtspServer = new RtspServer(responseProvider)) { - ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); - PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); - player.prepare(); - player.play(); - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); - player.release(); - - // Only setup the supported track (aac). - assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump); - DumpFileAsserts.assertOutput( - applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); - } + // Only setup the supported track (aac). + assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump); + DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); } @Test public void prepare_noSupportedTrack_throwsPreparationError() throws Exception { - - try (RtspServer rtspServer = + FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; + rtspServer = new RtspServer( new ResponseProvider( - clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel))) { - ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel)); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); - AtomicReference playbackError = new AtomicReference<>(); - player.prepare(); - player.addListener( - new Listener() { - @Override - public void onPlayerError(PlaybackException error) { - playbackError.set(error); - } - }); - RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); - player.release(); + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); - assertThat(playbackError.get()) - .hasCauseThat() - .hasMessageThat() - .contains("No playable track."); - } + assertThat(playbackError.get()).hasCauseThat().hasMessageThat().contains("No playable track."); + } + + @Test + public void prepare_withUdpUnsupportedWithFallback_fallsbackToTcpAndPlaysUntilEnd() + throws Exception { + FakeTcpDataSourceRtpDataChannel fakeTcpRtpDataChannel = new FakeTcpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpTcpDataChannelFactory = (trackId) -> fakeTcpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeTcpRtpDataChannel); + ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory = + new ForwardingRtpDataChannelFactory( + new UdpDataSourceRtpDataChannelFactory(DEFAULT_TIMEOUT_MS), rtpTcpDataChannelFactory); + rtspServer = new RtspServer(responseProviderSupportingOnlyTcp); + ExoPlayer player = + createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory); + + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + // Only setup the supported track (aac). + assertThat(responseProviderSupportingOnlyTcp.getDumpsForSetUpTracks()) + .containsExactly(aacRtpPacketStreamDump); + DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); + } + + @Test + public void prepare_withUdpUnsupportedWithoutFallback_throwsRtspPlaybackException() + throws Exception { + FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProvider = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeUdpRtpDataChannel); + rtspServer = new RtspServer(responseProvider); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); + + assertThat(playbackError.get()) + .hasCauseThat() + .isInstanceOf(RtspMediaSource.RtspPlaybackException.class); + assertThat(playbackError.get()) + .hasCauseThat() + .hasMessageThat() + .contains("No fallback data channel factory for TCP retry"); + } + + @Test + public void prepare_withUdpUnsupportedWithUdpFallback_throwsRtspUdpUnsupportedTransportException() + throws Exception { + FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeUdpRtpDataChannel); + ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory = + new ForwardingRtpDataChannelFactory(rtpDataChannelFactory, rtpDataChannelFactory); + rtspServer = new RtspServer(responseProviderSupportingOnlyTcp); + ExoPlayer player = + createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory); + + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); + + assertThat(playbackError.get()) + .hasCauseThat() + .isInstanceOf(RtspMediaSource.RtspUdpUnsupportedTransportException.class); + assertThat(playbackError.get()).hasCauseThat().hasMessageThat().isEqualTo("SETUP 461"); } private ExoPlayer createExoPlayer( @@ -163,16 +257,16 @@ public final class RtspPlaybackTest { return player; } - private static final class ResponseProvider implements RtspServer.ResponseProvider { + private static class ResponseProvider implements RtspServer.ResponseProvider { - private static final String SESSION_ID = "00000000"; + protected static final String SESSION_ID = "00000000"; - private final Clock clock; - private final ArrayList dumpsForSetUpTracks; - private final ImmutableList rtpPacketStreamDumps; + protected final Clock clock; + protected final ArrayList dumpsForSetUpTracks; + protected final ImmutableList rtpPacketStreamDumps; private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener; - private RtpPacketTransmitter packetTransmitter; + protected RtpPacketTransmitter packetTransmitter; /** * Creates a new instance. @@ -240,22 +334,54 @@ public final class RtspPlaybackTest { } } - private static final class FakeUdpDataSourceRtpDataChannel extends BaseDataSource - implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener { + private static final class ResponseProviderSupportingOnlyTcp extends ResponseProvider { - private static final int LOCAL_PORT = 40000; + /** + * Creates a new instance. + * + * @param clock The {@link Clock} used in the test. + * @param rtpPacketStreamDumps A list of {@link RtpPacketStreamDump}. + * @param binaryDataListener A {@link RtspMessageChannel.InterleavedBinaryDataListener} to send + * RTP data. + */ + public ResponseProviderSupportingOnlyTcp( + Clock clock, + List rtpPacketStreamDumps, + RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener) { + super(clock, rtpPacketStreamDumps, binaryDataListener); + } + + @Override + public RtspResponse getSetupResponse(Uri requestedUri, RtspHeaders headers) { + String transportHeaderValue = checkNotNull(headers.get(RtspHeaders.TRANSPORT)); + if (!transportHeaderValue.contains("TCP")) { + return new RtspResponse( + /* status= */ 461, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build()); + } + for (RtpPacketStreamDump rtpPacketStreamDump : rtpPacketStreamDumps) { + if (requestedUri.toString().contains(rtpPacketStreamDump.trackName)) { + dumpsForSetUpTracks.add(rtpPacketStreamDump); + packetTransmitter = new RtpPacketTransmitter(rtpPacketStreamDump, clock); + } + } + return new RtspResponse( + /* status= */ 200, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build()); + } + } + + private abstract static class FakeBaseDataSourceRtpDataChannel extends BaseDataSource + implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener { + protected static final int LOCAL_PORT = 40000; private final ConcurrentLinkedQueue packetQueue; - public FakeUdpDataSourceRtpDataChannel() { + public FakeBaseDataSourceRtpDataChannel() { super(/* isNetwork= */ false); packetQueue = new ConcurrentLinkedQueue<>(); } @Override - public String getTransport() { - return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1); - } + public abstract String getTransport(); @Override public int getLocalPort() { @@ -307,4 +433,49 @@ public final class RtspPlaybackTest { return byteToRead; } } + + private static final class FakeUdpDataSourceRtpDataChannel + extends FakeBaseDataSourceRtpDataChannel { + @Override + public String getTransport() { + return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1); + } + + @Override + public RtspMessageChannel.InterleavedBinaryDataListener getInterleavedBinaryDataListener() { + return null; + } + } + + private static final class FakeTcpDataSourceRtpDataChannel + extends FakeBaseDataSourceRtpDataChannel { + @Override + public String getTransport() { + return Util.formatInvariant( + "RTP/AVP/TCP;unicast;interleaved=%d-%d", LOCAL_PORT + 2, LOCAL_PORT + 3); + } + } + + private static class ForwardingRtpDataChannelFactory implements RtpDataChannel.Factory { + + private final RtpDataChannel.Factory rtpChannelFactory; + private final RtpDataChannel.Factory rtpFallbackChannelFactory; + + public ForwardingRtpDataChannelFactory( + RtpDataChannel.Factory rtpChannelFactory, + RtpDataChannel.Factory rtpFallbackChannelFactory) { + this.rtpChannelFactory = rtpChannelFactory; + this.rtpFallbackChannelFactory = rtpFallbackChannelFactory; + } + + @Override + public RtpDataChannel createAndOpenDataChannel(int trackId) throws IOException { + return rtpChannelFactory.createAndOpenDataChannel(trackId); + } + + @Override + public RtpDataChannel.Factory createFallbackDataChannelFactory() { + return rtpFallbackChannelFactory; + } + } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/HevcConfig.java b/libraries/extractor/src/main/java/androidx/media3/extractor/HevcConfig.java index cbe14e9b16..ad2c0851dc 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/HevcConfig.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/HevcConfig.java @@ -16,6 +16,7 @@ package androidx.media3.extractor; import androidx.annotation.Nullable; +import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.ParserException; import androidx.media3.common.util.CodecSpecificDataUtil; @@ -61,6 +62,9 @@ public final class HevcConfig { int bufferPosition = 0; int width = Format.NO_VALUE; int height = Format.NO_VALUE; + @C.ColorSpace int colorSpace = Format.NO_VALUE; + @C.ColorRange int colorRange = Format.NO_VALUE; + @C.ColorTransfer int colorTransfer = Format.NO_VALUE; float pixelWidthHeightRatio = 1; @Nullable String codecs = null; for (int i = 0; i < numberOfArrays; i++) { @@ -84,6 +88,9 @@ public final class HevcConfig { buffer, bufferPosition, bufferPosition + nalUnitLength); width = spsData.width; height = spsData.height; + colorSpace = spsData.colorSpace; + colorRange = spsData.colorRange; + colorTransfer = spsData.colorTransfer; pixelWidthHeightRatio = spsData.pixelWidthHeightRatio; codecs = CodecSpecificDataUtil.buildHevcCodecString( @@ -102,7 +109,15 @@ public final class HevcConfig { List initializationData = csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer); return new HevcConfig( - initializationData, lengthSizeMinusOne + 1, width, height, pixelWidthHeightRatio, codecs); + initializationData, + lengthSizeMinusOne + 1, + width, + height, + pixelWidthHeightRatio, + codecs, + colorSpace, + colorRange, + colorTransfer); } catch (ArrayIndexOutOfBoundsException e) { throw ParserException.createForMalformedContainer("Error parsing HEVC config", e); } @@ -129,6 +144,22 @@ public final class HevcConfig { /** The pixel width to height ratio. */ public final float pixelWidthHeightRatio; + /** + * The {@link C.ColorSpace} of the video or {@link Format#NO_VALUE} if unknown or not applicable. + */ + public final @C.ColorSpace int colorSpace; + + /** + * The {@link C.ColorRange} of the video or {@link Format#NO_VALUE} if unknown or not applicable. + */ + public final @C.ColorRange int colorRange; + + /** + * The {@link C.ColorTransfer} of the video or {@link Format#NO_VALUE} if unknown or not + * applicable. + */ + public final @C.ColorTransfer int colorTransfer; + /** * An RFC 6381 codecs string representing the video format, or {@code null} if not known. * @@ -142,12 +173,18 @@ public final class HevcConfig { int width, int height, float pixelWidthHeightRatio, - @Nullable String codecs) { + @Nullable String codecs, + @C.ColorSpace int colorSpace, + @C.ColorRange int colorRange, + @C.ColorTransfer int colorTransfer) { this.initializationData = initializationData; this.nalUnitLengthFieldLength = nalUnitLengthFieldLength; this.width = width; this.height = height; this.pixelWidthHeightRatio = pixelWidthHeightRatio; this.codecs = codecs; + this.colorSpace = colorSpace; + this.colorRange = colorRange; + this.colorTransfer = colorTransfer; } } 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 2a401cbbae..354b59f666 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/NalUnitUtil.java @@ -19,6 +19,8 @@ import static java.lang.Math.min; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.ColorInfo; +import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Log; @@ -110,6 +112,9 @@ public final class NalUnitUtil { public final int width; public final int height; public final float pixelWidthHeightRatio; + public final @C.ColorSpace int colorSpace; + public final @C.ColorRange int colorRange; + public final @C.ColorTransfer int colorTransfer; public H265SpsData( int generalProfileSpace, @@ -121,7 +126,10 @@ public final class NalUnitUtil { int seqParameterSetId, int width, int height, - float pixelWidthHeightRatio) { + float pixelWidthHeightRatio, + @C.ColorSpace int colorSpace, + @C.ColorRange int colorRange, + @C.ColorTransfer int colorTransfer) { this.generalProfileSpace = generalProfileSpace; this.generalTierFlag = generalTierFlag; this.generalProfileIdc = generalProfileIdc; @@ -132,6 +140,9 @@ public final class NalUnitUtil { this.width = width; this.height = height; this.pixelWidthHeightRatio = pixelWidthHeightRatio; + this.colorSpace = colorSpace; + this.colorRange = colorRange; + this.colorTransfer = colorTransfer; } } @@ -488,6 +499,10 @@ public final class NalUnitUtil { public static H265SpsData parseH265SpsNalUnitPayload( byte[] nalData, int nalOffset, int nalLimit) { ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, nalLimit); + // HDR related metadata. + @C.ColorSpace int colorSpace = Format.NO_VALUE; + @C.ColorRange int colorRange = Format.NO_VALUE; + @C.ColorTransfer int colorTransfer = Format.NO_VALUE; data.skipBits(4); // sps_video_parameter_set_id int maxSubLayersMinus1 = data.readBits(3); data.skipBit(); // sps_temporal_id_nesting_flag @@ -594,10 +609,17 @@ public final class NalUnitUtil { data.skipBit(); // overscan_appropriate_flag } if (data.readBit()) { // video_signal_type_present_flag - data.skipBits(4); // video_format, video_full_range_flag + data.skipBits(3); // video_format + boolean fullRangeFlag = data.readBit(); // video_full_range_flag if (data.readBit()) { // colour_description_present_flag - // colour_primaries, transfer_characteristics, matrix_coeffs - data.skipBits(24); + int colorPrimaries = data.readBits(8); // colour_primaries + int transferCharacteristics = data.readBits(8); // transfer_characteristics + data.skipBits(8); // matrix_coeffs + + colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries); + colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; + colorTransfer = + ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); } } if (data.readBit()) { // chroma_loc_info_present_flag @@ -622,7 +644,10 @@ public final class NalUnitUtil { seqParameterSetId, frameWidth, frameHeight, - pixelWidthHeightRatio); + pixelWidthHeightRatio, + colorSpace, + colorRange, + colorTransfer); } /** diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/Atom.java b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/Atom.java index 6256fa7f27..3f2cb72ad8 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/Atom.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/mp4/Atom.java @@ -176,6 +176,9 @@ import java.util.List; @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_ddts = 0x64647473; + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_udts = 0x75647473; + @SuppressWarnings("ConstantCaseForConstants") public static final int TYPE_tfdt = 0x74666474; 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 5e290bc67d..a405753f24 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 @@ -1164,6 +1164,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio; } codecs = hevcConfig.codecs; + colorSpace = hevcConfig.colorSpace; + colorRange = hevcConfig.colorRange; + colorTransfer = hevcConfig.colorTransfer; } else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) { @Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent); if (dolbyVisionConfig != null) { @@ -1173,6 +1176,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else if (childAtomType == Atom.TYPE_vpcC) { ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null); mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9; + parent.setPosition(childStartPosition + Atom.FULL_HEADER_SIZE); + // See vpcC atom syntax: https://www.webmproject.org/vp9/mp4/#syntax_1 + parent.skipBytes(2); // profile(8), level(8) + boolean fullRangeFlag = (parent.readUnsignedByte() & 1) != 0; + int colorPrimaries = parent.readUnsignedByte(); + int transferCharacteristics = parent.readUnsignedByte(); + colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries); + colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; + colorTransfer = + ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); } else if (childAtomType == Atom.TYPE_av1C) { ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null); mimeType = MimeTypes.VIDEO_AV1; @@ -1252,26 +1265,33 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } } else if (childAtomType == Atom.TYPE_colr) { - int colorType = parent.readInt(); - if (colorType == TYPE_nclx || colorType == TYPE_nclc) { - // For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and - // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html. - int colorPrimaries = parent.readUnsignedShort(); - int transferCharacteristics = parent.readUnsignedShort(); - parent.skipBytes(2); // matrix_coefficients. + // Only modify these values if they have not been previously established by the bitstream. + // If 'Atom.TYPE_hvcC' atom or 'Atom.TYPE_vpcC' is available, they will take precedence and + // overwrite any existing values. + if (colorSpace == Format.NO_VALUE + && colorRange == Format.NO_VALUE + && colorTransfer == Format.NO_VALUE) { + int colorType = parent.readInt(); + if (colorType == TYPE_nclx || colorType == TYPE_nclc) { + // For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html. + int colorPrimaries = parent.readUnsignedShort(); + int transferCharacteristics = parent.readUnsignedShort(); + parent.skipBytes(2); // matrix_coefficients. - // Only try and read full_range_flag if the box is long enough. It should be present in - // all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some - // device cameras record videos with type=nclx without this final flag (and therefore - // size=18): https://github.com/google/ExoPlayer/issues/9332 - boolean fullRangeFlag = - childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0; - colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries); - colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; - colorTransfer = - ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); - } else { - Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType)); + // Only try and read full_range_flag if the box is long enough. It should be present in + // all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some + // device cameras record videos with type=nclx without this final flag (and therefore + // size=18): https://github.com/google/ExoPlayer/issues/9332 + boolean fullRangeFlag = + childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0; + colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries); + colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; + colorTransfer = + ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); + } else { + Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType)); + } } } childPosition += childAtomSize; @@ -1560,7 +1580,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; // because these streams can carry simultaneously multiple representations of the same // audio. Use stereo by default. channelCount = 2; - } else if (childAtomType == Atom.TYPE_ddts) { + } else if (childAtomType == Atom.TYPE_ddts || childAtomType == Atom.TYPE_udts) { out.format = new Format.Builder() .setId(trackId) 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 59dd8543db..dd5f108bd1 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/NalUnitUtilTest.java @@ -194,6 +194,9 @@ public final class NalUnitUtilTest { assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1); assertThat(spsData.seqParameterSetId).isEqualTo(0); assertThat(spsData.width).isEqualTo(3840); + assertThat(spsData.colorSpace).isEqualTo(6); + assertThat(spsData.colorRange).isEqualTo(2); + assertThat(spsData.colorTransfer).isEqualTo(6); } private static byte[] buildTestData() { diff --git a/libraries/session/build.gradle b/libraries/session/build.gradle index 74e341395f..860137f376 100644 --- a/libraries/session/build.gradle +++ b/libraries/session/build.gradle @@ -43,6 +43,8 @@ dependencies { androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion testImplementation project(modulePrefix + 'test-utils') + testImplementation project(modulePrefix + 'test-utils-robolectric') + testImplementation project(modulePrefix + 'lib-exoplayer') testImplementation 'org.robolectric:robolectric:' + robolectricVersion } diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java b/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java index 037baff628..b9f6258594 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionRequest.java @@ -89,8 +89,8 @@ import androidx.media3.common.util.Util; int controllerInterfaceVersion = bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0); String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME)); - int pid = bundle.getInt(FIELD_PID, /* defaultValue= */ 0); - checkArgument(pid != 0); + checkArgument(bundle.containsKey(FIELD_PID)); + int pid = bundle.getInt(FIELD_PID); @Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS); return new ConnectionRequest( libraryVersion, 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 b6c487fcd2..2cf9f15305 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -33,8 +33,6 @@ import android.app.NotificationManager; import android.content.Context; import android.graphics.Bitmap; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import androidx.annotation.DoNotInline; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; @@ -245,7 +243,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi private final String channelId; @StringRes private final int channelNameResourceId; private final NotificationManager notificationManager; - private final Handler mainHandler; private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback; @DrawableRes private int smallIconResourceId; @@ -278,7 +275,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi notificationManager = checkStateNotNull( (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); - mainHandler = new Handler(Looper.getMainLooper()); smallIconResourceId = R.drawable.media3_notification_small_icon; } @@ -346,7 +342,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi pendingOnBitmapLoadedFutureCallback, // This callback must be executed on the next looper iteration, after this method has // returned a media notification. - mainHandler::post); + mediaSession.getImpl().getApplicationHandler()::post); } } } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java index cd0a0a1fca..e01ffcbf49 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java @@ -1795,7 +1795,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; currentTimeline = isQueueChanged ? QueueTimeline.create(newLegacyPlayerInfo.queue) - : new QueueTimeline((QueueTimeline) oldControllerInfo.playerInfo.timeline); + : ((QueueTimeline) oldControllerInfo.playerInfo.timeline).copy(); boolean isMetadataCompatChanged = oldLegacyPlayerInfo.mediaMetadataCompat != newLegacyPlayerInfo.mediaMetadataCompat @@ -1987,8 +1987,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; Integer mediaItemTransitionReason; boolean isOldTimelineEmpty = oldControllerInfo.playerInfo.timeline.isEmpty(); boolean isNewTimelineEmpty = newControllerInfo.playerInfo.timeline.isEmpty(); - int newCurrentMediaItemIndex = - newControllerInfo.playerInfo.sessionPositionInfo.positionInfo.mediaItemIndex; if (isOldTimelineEmpty && isNewTimelineEmpty) { // Still empty Timelines. discontinuityReason = null; @@ -2000,13 +1998,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } else { MediaItem oldCurrentMediaItem = checkStateNotNull(oldControllerInfo.playerInfo.getCurrentMediaItem()); - int oldCurrentMediaItemIndexInNewTimeline = - ((QueueTimeline) newControllerInfo.playerInfo.timeline).indexOf(oldCurrentMediaItem); - if (oldCurrentMediaItemIndexInNewTimeline == C.INDEX_UNSET) { + boolean oldCurrentMediaItemExistsInNewTimeline = + ((QueueTimeline) newControllerInfo.playerInfo.timeline).contains(oldCurrentMediaItem); + if (!oldCurrentMediaItemExistsInNewTimeline) { // Old current item is removed. discontinuityReason = Player.DISCONTINUITY_REASON_REMOVE; mediaItemTransitionReason = Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; - } else if (oldCurrentMediaItemIndexInNewTimeline == newCurrentMediaItemIndex) { + } else if (oldCurrentMediaItem.equals(newControllerInfo.playerInfo.getCurrentMediaItem())) { // Current item is the same. long oldCurrentPosition = MediaUtils.convertToCurrentPositionMs( diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java index fae9ae1fee..e9ebd7bd5c 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java @@ -35,6 +35,10 @@ public final class MediaNotification { /** * Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending * intents} for notifications. + * + *

    All methods will be called on the {@link Player#getApplicationLooper() application thread} + * of the {@link Player} associated with the {@link MediaSession} the notification is provided + * for. */ @UnstableApi public interface ActionFactory { @@ -109,10 +113,20 @@ public final class MediaNotification { * *

    The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat * notification channel}, which is required to show notification for {@code SDK_INT >= 26}. + * + *

    All methods will be called on the {@link Player#getApplicationLooper() application thread} + * of the {@link Player} associated with the {@link MediaSession} the notification is provided + * for. */ @UnstableApi public interface Provider { - /** Receives updates for a notification. */ + /** + * Receives updates for a notification. + * + *

    All methods will be called on the {@link Player#getApplicationLooper() application thread} + * of the {@link Player} associated with the {@link MediaSession} the notification is provided + * for. + */ interface Callback { /** * Called when a {@link MediaNotification} is changed. diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java index 30afcb411b..a65bbf2837 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -50,6 +50,8 @@ import java.util.concurrent.TimeoutException; /** * Manages media notifications for a {@link MediaSessionService} and sets the service as * foreground/background according to the player state. + * + *

    All methods must be called on the main thread. */ /* package */ final class MediaNotificationManager { @@ -96,11 +98,12 @@ import java.util.concurrent.TimeoutException; .setListener(listener) .setApplicationLooper(Looper.getMainLooper()) .buildAsync(); + controllerMap.put(session, controllerFuture); controllerFuture.addListener( () -> { try { MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); - listener.onConnected(); + listener.onConnected(shouldShowNotification(session)); controller.addListener(listener); } catch (CancellationException | ExecutionException @@ -111,7 +114,6 @@ import java.util.concurrent.TimeoutException; } }, mainExecutor); - controllerMap.put(session, controllerFuture); } public void removeSession(MediaSession session) { @@ -123,46 +125,19 @@ import java.util.concurrent.TimeoutException; } public void onCustomAction(MediaSession session, String action, Bundle extras) { - @Nullable ListenableFuture controllerFuture = controllerMap.get(session); - if (controllerFuture == null) { + @Nullable MediaController mediaController = getConnectedControllerForSession(session); + if (mediaController == null) { return; } - try { - MediaController mediaController = checkStateNotNull(Futures.getDone(controllerFuture)); - if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { - @Nullable SessionCommand customCommand = null; - for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { - if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM - && command.customAction.equals(action)) { - customCommand = command; - break; + // Let the notification provider handle the command first before forwarding it directly. + Util.postOrRun( + new Handler(session.getPlayer().getApplicationLooper()), + () -> { + if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { + mainExecutor.execute( + () -> sendCustomCommandIfCommandIsAvailable(mediaController, action)); } - } - if (customCommand != null - && mediaController.getAvailableSessionCommands().contains(customCommand)) { - ListenableFuture future = - mediaController.sendCustomCommand(customCommand, Bundle.EMPTY); - Futures.addCallback( - future, - new FutureCallback() { - @Override - public void onSuccess(SessionResult result) { - // Do nothing. - } - - @Override - public void onFailure(Throwable t) { - Log.w( - TAG, "custom command " + action + " produced an error: " + t.getMessage(), t); - } - }, - MoreExecutors.directExecutor()); - } - } - } catch (ExecutionException e) { - // We should never reach this. - throw new IllegalStateException(e); - } + }); } /** @@ -178,27 +153,42 @@ import java.util.concurrent.TimeoutException; } int notificationSequence = ++totalNotificationCount; + ImmutableList customLayout = checkStateNotNull(customLayoutMap.get(session)); MediaNotification.Provider.Callback callback = notification -> mainExecutor.execute( () -> onNotificationUpdated(notificationSequence, session, notification)); - - MediaNotification mediaNotification = - this.mediaNotificationProvider.createNotification( - session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback); - updateNotificationInternal(session, mediaNotification, startInForegroundRequired); + Util.postOrRun( + new Handler(session.getPlayer().getApplicationLooper()), + () -> { + MediaNotification mediaNotification = + this.mediaNotificationProvider.createNotification( + session, customLayout, actionFactory, callback); + mainExecutor.execute( + () -> + updateNotificationInternal( + session, mediaNotification, startInForegroundRequired)); + }); } public boolean isStartedInForeground() { return startedInForeground; } + /* package */ boolean shouldRunInForeground( + MediaSession session, boolean startInForegroundWhenPaused) { + @Nullable MediaController controller = getConnectedControllerForSession(session); + return controller != null + && (controller.getPlayWhenReady() || startInForegroundWhenPaused) + && (controller.getPlaybackState() == Player.STATE_READY + || controller.getPlaybackState() == Player.STATE_BUFFERING); + } + private void onNotificationUpdated( int notificationSequence, MediaSession session, MediaNotification mediaNotification) { if (notificationSequence == totalNotificationCount) { boolean startInForegroundRequired = - MediaSessionService.shouldRunInForeground( - session, /* startInForegroundWhenPaused= */ false); + shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false); updateNotificationInternal(session, mediaNotification, startInForegroundRequired); } } @@ -236,8 +226,7 @@ import java.util.concurrent.TimeoutException; private void maybeStopForegroundService(boolean removeNotifications) { List sessions = mediaSessionService.getSessions(); for (int i = 0; i < sessions.size(); i++) { - if (MediaSessionService.shouldRunInForeground( - sessions.get(i), /* startInForegroundWhenPaused= */ false)) { + if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) { return; } } @@ -251,9 +240,56 @@ import java.util.concurrent.TimeoutException; } } - private static boolean shouldShowNotification(MediaSession session) { - Player player = session.getPlayer(); - return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE; + private boolean shouldShowNotification(MediaSession session) { + MediaController controller = getConnectedControllerForSession(session); + return controller != null + && !controller.getCurrentTimeline().isEmpty() + && controller.getPlaybackState() != Player.STATE_IDLE; + } + + @Nullable + private MediaController getConnectedControllerForSession(MediaSession session) { + @Nullable ListenableFuture controllerFuture = controllerMap.get(session); + if (controllerFuture == null) { + return null; + } + try { + return Futures.getDone(controllerFuture); + } catch (ExecutionException exception) { + // We should never reach this. + throw new IllegalStateException(exception); + } + } + + private void sendCustomCommandIfCommandIsAvailable( + MediaController mediaController, String action) { + @Nullable SessionCommand customCommand = null; + for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { + if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM + && command.customAction.equals(action)) { + customCommand = command; + break; + } + } + if (customCommand != null + && mediaController.getAvailableSessionCommands().contains(customCommand)) { + ListenableFuture future = + mediaController.sendCustomCommand(customCommand, Bundle.EMPTY); + Futures.addCallback( + future, + new FutureCallback() { + @Override + public void onSuccess(SessionResult result) { + // Do nothing. + } + + @Override + public void onFailure(Throwable t) { + Log.w(TAG, "custom command " + action + " produced an error: " + t.getMessage(), t); + } + }, + MoreExecutors.directExecutor()); + } } private static final class MediaControllerListener @@ -271,8 +307,8 @@ import java.util.concurrent.TimeoutException; this.customLayoutMap = customLayoutMap; } - public void onConnected() { - if (shouldShowNotification(session)) { + public void onConnected(boolean shouldShowNotification) { + if (shouldShowNotification) { mediaSessionService.onUpdateNotificationInternal( session, /* startInForegroundWhenPaused= */ false); } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index 3c82ec75d2..475e13020e 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -701,6 +701,9 @@ public class MediaSession { * * * + *

    Interoperability: This call has no effect when called for a {@linkplain + * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. + * * @param controller The controller to specify layout. * @param layout The ordered list of {@link CommandButton}. */ @@ -793,6 +796,9 @@ public class MediaSession { * *

    This is a synchronous call and doesn't wait for results from the controller. * + *

    Interoperability: This call has no effect when called for a {@linkplain + * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. + * * @param controller The controller to send the extras to. * @param sessionExtras The session extras. */ @@ -816,6 +822,9 @@ public class MediaSession { * *

    A command is not accepted if it is not a custom command. * + *

    Interoperability: This call has no effect when called for a {@linkplain + * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. + * * @param controller The controller to send the custom command to. * @param command A custom command. * @param args A {@link Bundle} for additional arguments. May be empty. @@ -890,12 +899,20 @@ public class MediaSession { impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs); } - /** Sets the {@linkplain Listener listener}. */ + /** + * Sets the {@linkplain Listener listener}. + * + *

    This method must be called on the main thread. + */ /* package */ void setListener(Listener listener) { impl.setMediaSessionListener(listener); } - /** Clears the {@linkplain Listener listener}. */ + /** + * Clears the {@linkplain Listener listener}. + * + *

    This method must be called on the main thread. + */ /* package */ void clearListener() { impl.clearMediaSessionListener(); } @@ -1426,7 +1443,11 @@ public class MediaSession { default void onRenderedFirstFrame(int seq) throws RemoteException {} } - /** Listener for media session events */ + /** + * Listener for media session events. + * + *

    All methods must be called on the main thread. + */ /* package */ interface Listener { /** diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index 67acd1aca6..ee865ba11d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -15,21 +15,17 @@ */ package androidx.media3.session; -import static androidx.media3.common.Player.COMMAND_GET_TRACKS; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; -import static androidx.media3.common.util.Util.castNonNull; import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED; import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN; import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import android.app.PendingIntent; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; @@ -43,7 +39,6 @@ import android.os.Process; import android.os.RemoteException; import android.os.SystemClock; import android.support.v4.media.session.MediaSessionCompat; -import android.view.KeyEvent; import androidx.annotation.FloatRange; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; @@ -66,7 +61,6 @@ import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.text.CueGroup; import androidx.media3.common.util.Log; -import androidx.media3.common.util.Util; import androidx.media3.session.MediaSession.ControllerCb; import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.session.MediaSession.MediaItemsWithStartPosition; @@ -74,9 +68,11 @@ import androidx.media3.session.SequencedFutureManager.SequencedFuture; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutionException; import org.checkerframework.checker.initialization.qual.Initialized; /* package */ class MediaSessionImpl { @@ -115,13 +111,13 @@ import org.checkerframework.checker.initialization.qual.Initialized; private final SessionToken sessionToken; private final MediaSession instance; @Nullable private final PendingIntent sessionActivity; - private final PendingIntent mediaButtonIntent; - @Nullable private final BroadcastReceiver broadcastReceiver; private final Handler applicationHandler; private final BitmapLoader bitmapLoader; private final Runnable periodicSessionPositionInfoUpdateRunnable; + private final Handler mainHandler; @Nullable private PlayerListener playerListener; + @Nullable private MediaSession.Listener mediaSessionListener; private PlayerInfo playerInfo; @@ -156,6 +152,7 @@ import org.checkerframework.checker.initialization.qual.Initialized; sessionStub = new MediaSessionStub(thisRef); this.sessionActivity = sessionActivity; + mainHandler = new Handler(Looper.getMainLooper()); applicationHandler = new Handler(player.getApplicationLooper()); this.callback = callback; this.bitmapLoader = bitmapLoader; @@ -189,52 +186,21 @@ import org.checkerframework.checker.initialization.qual.Initialized; sessionStub, tokenExtras); - @Nullable ComponentName mbrComponent; synchronized (STATIC_LOCK) { if (!componentNamesInitialized) { - serviceComponentName = + MediaSessionImpl.serviceComponentName = getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE); - if (serviceComponentName == null) { - serviceComponentName = + if (MediaSessionImpl.serviceComponentName == null) { + MediaSessionImpl.serviceComponentName = getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE); } componentNamesInitialized = true; } - mbrComponent = serviceComponentName; - } - int pendingIntentFlagMutable = Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0; - if (mbrComponent == null) { - // No service to revive playback after it's dead. - // Create a PendingIntent that points to the runtime broadcast receiver. - Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri); - intent.setPackage(context.getPackageName()); - mediaButtonIntent = - PendingIntent.getBroadcast( - context, /* requestCode= */ 0, intent, pendingIntentFlagMutable); - - // Creates a fake ComponentName for MediaSessionCompat in pre-L. - mbrComponent = new ComponentName(context, context.getClass()); - - // Create and register a BroadcastReceiver for receiving PendingIntent. - broadcastReceiver = new MediaButtonReceiver(); - IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON); - filter.addDataScheme(castNonNull(sessionUri.getScheme())); - Util.registerReceiverNotExported(context, broadcastReceiver, filter); - } else { - // Has MediaSessionService to revive playback after it's dead. - Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri); - intent.setComponent(mbrComponent); - if (Util.SDK_INT >= 26) { - mediaButtonIntent = - PendingIntent.getForegroundService(context, 0, intent, pendingIntentFlagMutable); - } else { - mediaButtonIntent = PendingIntent.getService(context, 0, intent, pendingIntentFlagMutable); - } - broadcastReceiver = null; } sessionLegacyStub = - new MediaSessionLegacyStub(thisRef, mbrComponent, mediaButtonIntent, applicationHandler); + new MediaSessionLegacyStub( + /* session= */ thisRef, sessionUri, serviceComponentName, applicationHandler); PlayerWrapper playerWrapper = new PlayerWrapper(player); this.playerWrapper = playerWrapper; @@ -278,8 +244,7 @@ import org.checkerframework.checker.initialization.qual.Initialized; } playerInfo = newPlayerWrapper.createPlayerInfoForBundling(); - onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage( - /* excludeTimeline= */ false, /* excludeTracks= */ false); + handleAvailablePlayerCommandsChanged(newPlayerWrapper.getAvailableCommands()); } public void release() { @@ -305,10 +270,6 @@ import org.checkerframework.checker.initialization.qual.Initialized; Log.w(TAG, "Exception thrown while closing", e); } sessionLegacyStub.release(); - mediaButtonIntent.cancel(); - if (broadcastReceiver != null) { - context.unregisterReceiver(broadcastReceiver); - } sessionStub.release(); } @@ -395,7 +356,7 @@ import org.checkerframework.checker.initialization.qual.Initialized; private void dispatchOnPlayerInfoChanged( PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) { - + playerInfo = sessionStub.generateAndCacheUniqueTrackGroupIds(playerInfo); List controllers = sessionStub.getConnectedControllersManager().getConnectedControllers(); for (int i = 0; i < controllers.size(); i++) { @@ -589,12 +550,25 @@ import org.checkerframework.checker.initialization.qual.Initialized; } /* package */ void onNotificationRefreshRequired() { - if (this.mediaSessionListener != null) { - this.mediaSessionListener.onNotificationRefreshRequired(instance); - } + postOrRun( + mainHandler, + () -> { + if (this.mediaSessionListener != null) { + this.mediaSessionListener.onNotificationRefreshRequired(instance); + } + }); } /* package */ boolean onPlayRequested() { + if (Looper.myLooper() != Looper.getMainLooper()) { + SettableFuture playRequested = SettableFuture.create(); + mainHandler.post(() -> playRequested.set(onPlayRequested())); + try { + return playRequested.get(); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } if (this.mediaSessionListener != null) { return this.mediaSessionListener.onPlayRequested(instance); } @@ -772,6 +746,20 @@ import org.checkerframework.checker.initialization.qual.Initialized; } } + private void handleAvailablePlayerCommandsChanged(Player.Commands availableCommands) { + // Update PlayerInfo and do not force exclude elements in case they need to be updated because + // an available command has been removed. + onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage( + /* excludeTimeline= */ false, /* excludeTracks= */ false); + dispatchRemoteControllerTaskWithoutReturn( + (callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands)); + + // Forcefully update playback info to update VolumeProviderCompat in case + // COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed. + dispatchRemoteControllerTaskToLegacyStub( + (callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo)); + } + /* @FunctionalInterface */ interface RemoteControllerTask { @@ -1182,16 +1170,7 @@ import org.checkerframework.checker.initialization.qual.Initialized; if (player == null) { return; } - boolean excludeTracks = !availableCommands.contains(COMMAND_GET_TRACKS); - session.onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage( - /* excludeTimeline= */ false, excludeTracks); - session.dispatchRemoteControllerTaskWithoutReturn( - (callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands)); - - // Forcefully update playback info to update VolumeProviderCompat in case - // COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed. - session.dispatchRemoteControllerTaskToLegacyStub( - (callback, seq) -> callback.onDeviceInfoChanged(seq, session.playerInfo.deviceInfo)); + session.handleAvailablePlayerCommandsChanged(availableCommands); } @Override @@ -1281,26 +1260,6 @@ import org.checkerframework.checker.initialization.qual.Initialized; } } - // TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver - private final class MediaButtonReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) { - return; - } - Uri sessionUri = intent.getData(); - if (!Util.areEqual(sessionUri, MediaSessionImpl.this.sessionUri)) { - return; - } - KeyEvent keyEvent = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); - if (keyEvent == null) { - return; - } - getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); - } - } - private class PlayerInfoChangedHandler extends Handler { private static final int MSG_PLAYER_INFO_CHANGED = 1; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 13cf696db0..4872383ebb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -35,6 +35,7 @@ import static androidx.media3.common.Player.STATE_ENDED; import static androidx.media3.common.Player.STATE_IDLE; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; +import static androidx.media3.common.util.Util.castNonNull; import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES; import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM; @@ -43,9 +44,13 @@ import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import static androidx.media3.session.SessionResult.RESULT_SUCCESS; import android.app.PendingIntent; +import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; @@ -107,6 +112,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static final String TAG = "MediaSessionLegacyStub"; + private static final int PENDING_INTENT_FLAG_MUTABLE = + Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0; private static final String DEFAULT_MEDIA_SESSION_TAG_PREFIX = "androidx.media3.session.id"; private static final String DEFAULT_MEDIA_SESSION_TAG_DELIM = "."; @@ -122,6 +129,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler; private final MediaSessionCompat sessionCompat; private final String appPackageName; + @Nullable private final MediaButtonReceiver runtimeBroadcastReceiver; + private final boolean canResumePlaybackOnStart; @Nullable private VolumeProviderCompat volumeProviderCompat; private volatile long connectionTimeoutMs; @@ -130,8 +139,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public MediaSessionLegacyStub( MediaSessionImpl session, - ComponentName mbrComponent, - PendingIntent mediaButtonIntent, + Uri sessionUri, + @Nullable ComponentName serviceComponentName, Handler handler) { sessionImpl = session; Context context = sessionImpl.getContext(); @@ -145,6 +154,44 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; connectedControllersManager = new ConnectedControllersManager<>(session); connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS; + // Select a media button receiver component. + ComponentName receiverComponentName = queryPackageManagerForMediaButtonReceiver(context); + // Assume an app that intentionally puts a `MediaButtonReceiver` into the manifest has + // implemented some kind of resumption of the last recently played media item. + canResumePlaybackOnStart = receiverComponentName != null; + if (receiverComponentName == null) { + receiverComponentName = serviceComponentName; + } + Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri); + PendingIntent mediaButtonIntent; + if (receiverComponentName == null) { + // Neither a media button receiver from the app manifest nor a service available that could + // handle media button events. Create a runtime receiver and a pending intent for it. + runtimeBroadcastReceiver = new MediaButtonReceiver(); + IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON); + filter.addDataScheme(castNonNull(sessionUri.getScheme())); + Util.registerReceiverNotExported(context, runtimeBroadcastReceiver, filter); + // Create a pending intent to be broadcast to the receiver. + intent.setPackage(context.getPackageName()); + mediaButtonIntent = + PendingIntent.getBroadcast( + context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE); + // Creates a fake ComponentName for MediaSessionCompat in pre-L. + receiverComponentName = new ComponentName(context, context.getClass()); + } else { + intent.setComponent(receiverComponentName); + mediaButtonIntent = + Objects.equals(serviceComponentName, receiverComponentName) + ? (Util.SDK_INT >= 26 + ? PendingIntent.getForegroundService( + context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE) + : PendingIntent.getService( + context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE)) + : PendingIntent.getBroadcast( + context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE); + runtimeBroadcastReceiver = null; + } + String sessionCompatId = TextUtils.join( DEFAULT_MEDIA_SESSION_TAG_DELIM, @@ -153,7 +200,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; new MediaSessionCompat( context, sessionCompatId, - mbrComponent, + receiverComponentName, mediaButtonIntent, session.getToken().getExtras()); @@ -168,12 +215,38 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; sessionCompat.setCallback(thisRef, handler); } + @Nullable + private static ComponentName queryPackageManagerForMediaButtonReceiver(Context context) { + PackageManager pm = context.getPackageManager(); + Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + queryIntent.setPackage(context.getPackageName()); + List resolveInfos = pm.queryBroadcastReceivers(queryIntent, /* flags= */ 0); + if (resolveInfos.size() == 1) { + ResolveInfo resolveInfo = resolveInfos.get(0); + return new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name); + } else if (resolveInfos.isEmpty()) { + return null; + } else { + throw new IllegalStateException( + "Expected 1 broadcast receiver that handles " + + Intent.ACTION_MEDIA_BUTTON + + ", found " + + resolveInfos.size()); + } + } + /** Starts to receive commands. */ public void start() { sessionCompat.setActive(true); } public void release() { + if (!canResumePlaybackOnStart) { + setMediaButtonReceiver(sessionCompat, /* mediaButtonReceiverIntent= */ null); + } + if (runtimeBroadcastReceiver != null) { + sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver); + } sessionCompat.release(); } @@ -832,6 +905,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; sessionCompat.setMetadata(metadataCompat); } + @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. + private static void setMediaButtonReceiver( + MediaSessionCompat sessionCompat, @Nullable PendingIntent mediaButtonReceiverIntent) { + sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent); + } + @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List queue) { sessionCompat.setQueue(queue); @@ -987,6 +1066,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; sessionImpl.getSessionCompat().setExtras(sessionExtras); } + @Override + public void sendCustomCommand(int seq, SessionCommand command, Bundle args) { + sessionImpl.getSessionCompat().sendSessionEvent(command.customAction, args); + } + @Override public void onPlayWhenReadyChanged( int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason) @@ -1237,11 +1321,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; lastMediaMetadata = newMediaMetadata; lastDurationMs = newDurationMs; - if (currentMediaItem == null) { - setMetadata(sessionCompat, /* metadataCompat= */ null); - return; - } - @Nullable Bitmap artworkBitmap = null; ListenableFuture bitmapFuture = sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata); @@ -1353,4 +1432,24 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; private static String getBitmapLoadErrorMessage(Throwable throwable) { return "Failed to load bitmap: " + throwable.getMessage(); } + + // TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver + private final class MediaButtonReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (!Util.areEqual(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)) { + return; + } + Uri sessionUri = intent.getData(); + if (!Util.areEqual(sessionUri, sessionUri)) { + return; + } + KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); + if (keyEvent == null) { + return; + } + getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); + } + } } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java index 30b76997f5..69f41b2e22 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi; import androidx.collection.ArrayMap; import androidx.media.MediaBrowserServiceCompat; import androidx.media.MediaSessionManager; -import androidx.media3.common.Player; import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; @@ -183,7 +182,6 @@ public abstract class MediaSessionService extends Service { @Nullable private Listener listener; - @GuardedBy("lock") private boolean defaultMethodCalled; /** Creates a service. */ @@ -198,6 +196,8 @@ public abstract class MediaSessionService extends Service { * Called when the service is created. * *

    Override this method if you need your own initialization. + * + *

    This method will be called on the main thread. */ @CallSuper @Override @@ -234,7 +234,7 @@ public abstract class MediaSessionService extends Service { *

    For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link * ControllerInfo#getConnectionHints()} have no meaning. * - *

    This method is always called on the main thread. + *

    This method will be called on the main thread. * * @param controllerInfo The information of the controller that is trying to connect. * @return A {@link MediaSession} for the controller, or {@code null} to reject the connection. @@ -251,6 +251,8 @@ public abstract class MediaSessionService extends Service { *

    The added session will be removed automatically {@linkplain MediaSession#release() when the * session is released}. * + *

    This method can be called from any thread. + * * @param session A session to be added. * @see #removeSession(MediaSession) * @see #getSessions() @@ -268,8 +270,12 @@ public abstract class MediaSessionService extends Service { // Session has returned for the first time. Register callbacks. // TODO(b/191644474): Check whether the session is registered to multiple services. MediaNotificationManager notificationManager = getMediaNotificationManager(); - postOrRun(mainHandler, () -> notificationManager.addSession(session)); - session.setListener(new MediaSessionListener()); + postOrRun( + mainHandler, + () -> { + notificationManager.addSession(session); + session.setListener(new MediaSessionListener()); + }); } } @@ -277,6 +283,8 @@ public abstract class MediaSessionService extends Service { * Removes a {@link MediaSession} from this service. This is not necessary for most media apps. * See Supporting Multiple Sessions for details. * + *

    This method can be called from any thread. + * * @param session A session to be removed. * @see #addSession(MediaSession) * @see #getSessions() @@ -288,13 +296,19 @@ public abstract class MediaSessionService extends Service { sessions.remove(session.getId()); } MediaNotificationManager notificationManager = getMediaNotificationManager(); - postOrRun(mainHandler, () -> notificationManager.removeSession(session)); - session.clearListener(); + postOrRun( + mainHandler, + () -> { + notificationManager.removeSession(session); + session.clearListener(); + }); } /** * Returns the list of {@linkplain MediaSession sessions} that you've added to this service via * {@link #addSession} or {@link #onGetSession(ControllerInfo)}. + * + *

    This method can be called from any thread. */ public final List getSessions() { synchronized (lock) { @@ -305,6 +319,8 @@ public abstract class MediaSessionService extends Service { /** * Returns whether {@code session} has been added to this service via {@link #addSession} or * {@link #onGetSession(ControllerInfo)}. + * + *

    This method can be called from any thread. */ public final boolean isSessionAdded(MediaSession session) { synchronized (lock) { @@ -312,7 +328,11 @@ public abstract class MediaSessionService extends Service { } } - /** Sets the {@linkplain Listener listener}. */ + /** + * Sets the {@linkplain Listener listener}. + * + *

    This method can be called from any thread. + */ @UnstableApi public final void setListener(Listener listener) { synchronized (lock) { @@ -320,7 +340,11 @@ public abstract class MediaSessionService extends Service { } } - /** Clears the {@linkplain Listener listener}. */ + /** + * Clears the {@linkplain Listener listener}. + * + *

    This method can be called from any thread. + */ @UnstableApi public final void clearListener() { synchronized (lock) { @@ -335,6 +359,8 @@ public abstract class MediaSessionService extends Service { * controllers}. In this case, the intent will have the action {@link #SERVICE_INTERFACE}. * Override this method if this service also needs to handle actions other than {@link * #SERVICE_INTERFACE}. + * + *

    This method will be called on the main thread. */ @CallSuper @Override @@ -378,6 +404,8 @@ public abstract class MediaSessionService extends Service { *

    The default implementation handles the incoming media button events. In this case, the * intent will have the action {@link Intent#ACTION_MEDIA_BUTTON}. Override this method if this * service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}. + * + *

    This method will be called on the main thread. */ @CallSuper @Override @@ -417,6 +445,8 @@ public abstract class MediaSessionService extends Service { * Called when the service is no longer used and is being removed. * *

    Override this method if you need your own clean up. + * + *

    This method will be called on the main thread. */ @CallSuper @Override @@ -456,7 +486,7 @@ public abstract class MediaSessionService extends Service { * @param session A session that needs notification update. */ public void onUpdateNotification(MediaSession session) { - setDefaultMethodCalled(true); + defaultMethodCalled = true; } /** @@ -483,13 +513,15 @@ public abstract class MediaSessionService extends Service { *

    Apps targeting {@code SDK_INT >= 28} must request the permission, {@link * android.Manifest.permission#FOREGROUND_SERVICE}. * + *

    This method will be called on the main thread. + * * @param session A session that needs notification update. * @param startInForegroundRequired Whether the service is required to start in the foreground. */ @UnstableApi public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) { onUpdateNotification(session); - if (isDefaultMethodCalled()) { + if (defaultMethodCalled) { getMediaNotificationManager().updateNotification(session, startInForegroundRequired); } } @@ -498,6 +530,8 @@ public abstract class MediaSessionService extends Service { * Sets the {@link MediaNotification.Provider} to customize notifications. * *

    This should be called before {@link #onCreate()} returns. + * + *

    This method can be called from any thread. */ @UnstableApi protected final void setMediaNotificationProvider( @@ -514,11 +548,16 @@ public abstract class MediaSessionService extends Service { } } + /** + * Triggers notification update and handles {@code ForegroundServiceStartNotAllowedException}. + * + *

    This method will be called on the main thread. + */ /* package */ boolean onUpdateNotificationInternal( MediaSession session, boolean startInForegroundWhenPaused) { try { boolean startInForegroundRequired = - shouldRunInForeground(session, startInForegroundWhenPaused); + getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused); onUpdateNotification(session, startInForegroundRequired); } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { @@ -531,14 +570,6 @@ public abstract class MediaSessionService extends Service { return true; } - /* package */ static boolean shouldRunInForeground( - MediaSession session, boolean startInForegroundWhenPaused) { - Player player = session.getPlayer(); - return (player.getPlayWhenReady() || startInForegroundWhenPaused) - && (player.getPlaybackState() == Player.STATE_READY - || player.getPlaybackState() == Player.STATE_BUFFERING); - } - private MediaNotificationManager getMediaNotificationManager() { synchronized (lock) { if (mediaNotificationManager == null) { @@ -570,18 +601,6 @@ public abstract class MediaSessionService extends Service { } } - private boolean isDefaultMethodCalled() { - synchronized (lock) { - return this.defaultMethodCalled; - } - } - - private void setDefaultMethodCalled(boolean defaultMethodCalled) { - synchronized (lock) { - this.defaultMethodCalled = defaultMethodCalled; - } - } - @RequiresApi(31) private void onForegroundServiceStartNotAllowedException() { mainHandler.post( diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java index 3c0b27a775..2480dd9951 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -70,7 +70,10 @@ import androidx.media3.common.MediaMetadata; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; import androidx.media3.common.Rating; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; import androidx.media3.common.TrackSelectionParameters; +import androidx.media3.common.Tracks; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.Consumer; @@ -82,6 +85,7 @@ import androidx.media3.session.MediaSession.ControllerCb; import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.session.MediaSession.MediaItemsWithStartPosition; import androidx.media3.session.SessionCommand.CommandCode; +import com.google.common.collect.ImmutableBiMap; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -113,6 +117,9 @@ import java.util.concurrent.ExecutionException; private final ConnectedControllersManager connectedControllersManager; private final Set pendingControllers; + private ImmutableBiMap trackGroupIdMap; + private int nextUniqueTrackGroupIdPrefix; + public MediaSessionStub(MediaSessionImpl sessionImpl) { // Initialize members with params. this.sessionImpl = new WeakReference<>(sessionImpl); @@ -120,6 +127,7 @@ import java.util.concurrent.ExecutionException; connectedControllersManager = new ConnectedControllersManager<>(sessionImpl); // ConcurrentHashMap has a bug in APIs 21-22 that can result in lost updates. pendingControllers = Collections.synchronizedSet(new HashSet<>()); + trackGroupIdMap = ImmutableBiMap.of(); } public ConnectedControllersManager getConnectedControllersManager() { @@ -493,6 +501,7 @@ import java.util.concurrent.ExecutionException; // session/controller. PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper(); PlayerInfo playerInfo = playerWrapper.createPlayerInfoForBundling(); + playerInfo = generateAndCacheUniqueTrackGroupIds(playerInfo); ConnectionState state = new ConnectionState( MediaLibraryInfo.VERSION_INT, @@ -1435,7 +1444,11 @@ import java.util.concurrent.ExecutionException; sequenceNumber, COMMAND_SET_TRACK_SELECTION_PARAMETERS, sendSessionResultSuccess( - player -> player.setTrackSelectionParameters(trackSelectionParameters))); + player -> { + TrackSelectionParameters updatedParameters = + updateOverridesUsingUniqueTrackGroupIds(trackSelectionParameters); + player.setTrackSelectionParameters(updatedParameters); + })); } ////////////////////////////////////////////////////////////////////////////////////////////// @@ -1622,6 +1635,65 @@ import java.util.concurrent.ExecutionException; librarySessionImpl.onUnsubscribeOnHandler(controller, parentId))); } + /* package */ PlayerInfo generateAndCacheUniqueTrackGroupIds(PlayerInfo playerInfo) { + ImmutableList trackGroups = playerInfo.currentTracks.getGroups(); + ImmutableList.Builder updatedTrackGroups = ImmutableList.builder(); + ImmutableBiMap.Builder updatedTrackGroupIdMap = ImmutableBiMap.builder(); + for (int i = 0; i < trackGroups.size(); i++) { + Tracks.Group trackGroup = trackGroups.get(i); + TrackGroup mediaTrackGroup = trackGroup.getMediaTrackGroup(); + @Nullable String uniqueId = trackGroupIdMap.get(mediaTrackGroup); + if (uniqueId == null) { + uniqueId = generateUniqueTrackGroupId(mediaTrackGroup); + } + updatedTrackGroupIdMap.put(mediaTrackGroup, uniqueId); + updatedTrackGroups.add(trackGroup.copyWithId(uniqueId)); + } + trackGroupIdMap = updatedTrackGroupIdMap.buildOrThrow(); + playerInfo = playerInfo.copyWithCurrentTracks(new Tracks(updatedTrackGroups.build())); + if (playerInfo.trackSelectionParameters.overrides.isEmpty()) { + return playerInfo; + } + TrackSelectionParameters.Builder updatedTrackSelectionParameters = + playerInfo.trackSelectionParameters.buildUpon().clearOverrides(); + for (TrackSelectionOverride override : playerInfo.trackSelectionParameters.overrides.values()) { + TrackGroup trackGroup = override.mediaTrackGroup; + @Nullable String uniqueId = trackGroupIdMap.get(trackGroup); + if (uniqueId != null) { + updatedTrackSelectionParameters.addOverride( + new TrackSelectionOverride(trackGroup.copyWithId(uniqueId), override.trackIndices)); + } else { + updatedTrackSelectionParameters.addOverride(override); + } + } + return playerInfo.copyWithTrackSelectionParameters(updatedTrackSelectionParameters.build()); + } + + private TrackSelectionParameters updateOverridesUsingUniqueTrackGroupIds( + TrackSelectionParameters trackSelectionParameters) { + if (trackSelectionParameters.overrides.isEmpty()) { + return trackSelectionParameters; + } + TrackSelectionParameters.Builder updateTrackSelectionParameters = + trackSelectionParameters.buildUpon().clearOverrides(); + for (TrackSelectionOverride override : trackSelectionParameters.overrides.values()) { + TrackGroup trackGroup = override.mediaTrackGroup; + @Nullable TrackGroup originalTrackGroup = trackGroupIdMap.inverse().get(trackGroup.id); + if (originalTrackGroup != null + && override.mediaTrackGroup.length == originalTrackGroup.length) { + updateTrackSelectionParameters.addOverride( + new TrackSelectionOverride(originalTrackGroup, override.trackIndices)); + } else { + updateTrackSelectionParameters.addOverride(override); + } + } + return updateTrackSelectionParameters.build(); + } + + private String generateUniqueTrackGroupId(TrackGroup trackGroup) { + return Util.intToStringMaxRadix(nextUniqueTrackGroupIdPrefix++) + "-" + trackGroup.id; + } + /** Common interface for code snippets to handle all incoming commands from the controller. */ private interface SessionTask { T run(K sessionImpl, ControllerInfo controller, int sequenceNumber); diff --git a/libraries/session/src/main/java/androidx/media3/session/QueueTimeline.java b/libraries/session/src/main/java/androidx/media3/session/QueueTimeline.java index a7dc94c511..2901789217 100644 --- a/libraries/session/src/main/java/androidx/media3/session/QueueTimeline.java +++ b/libraries/session/src/main/java/androidx/media3/session/QueueTimeline.java @@ -16,7 +16,6 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkArgument; -import static androidx.media3.common.util.Assertions.checkNotNull; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat.QueueItem; @@ -27,11 +26,8 @@ import androidx.media3.common.Timeline; import androidx.media3.common.util.Util; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** * An immutable class to represent the current {@link Timeline} backed by {@linkplain QueueItem @@ -45,42 +41,33 @@ import java.util.Map; /* package */ final class QueueTimeline extends Timeline { public static final QueueTimeline DEFAULT = - new QueueTimeline(ImmutableList.of(), ImmutableMap.of(), /* fakeMediaItem= */ null); + new QueueTimeline(ImmutableList.of(), /* fakeMediaItem= */ null); private static final Object FAKE_WINDOW_UID = new Object(); - private final ImmutableList mediaItems; - private final ImmutableMap mediaItemToQueueIdMap; + private final ImmutableList queuedMediaItems; @Nullable private final MediaItem fakeMediaItem; - /** Creates a new instance. */ - public QueueTimeline(QueueTimeline queueTimeline) { - this.mediaItems = queueTimeline.mediaItems; - this.mediaItemToQueueIdMap = queueTimeline.mediaItemToQueueIdMap; - this.fakeMediaItem = queueTimeline.fakeMediaItem; - } - private QueueTimeline( - ImmutableList mediaItems, - ImmutableMap mediaItemToQueueIdMap, - @Nullable MediaItem fakeMediaItem) { - this.mediaItems = mediaItems; - this.mediaItemToQueueIdMap = mediaItemToQueueIdMap; + ImmutableList queuedMediaItems, @Nullable MediaItem fakeMediaItem) { + this.queuedMediaItems = queuedMediaItems; this.fakeMediaItem = fakeMediaItem; } /** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */ public static QueueTimeline create(List queue) { - ImmutableList.Builder mediaItemsBuilder = new ImmutableList.Builder<>(); - ImmutableMap.Builder mediaItemToQueueIdMap = new ImmutableMap.Builder<>(); + ImmutableList.Builder queuedMediaItemsBuilder = new ImmutableList.Builder<>(); for (int i = 0; i < queue.size(); i++) { QueueItem queueItem = queue.get(i); MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem); - mediaItemsBuilder.add(mediaItem); - mediaItemToQueueIdMap.put(mediaItem, queueItem.getQueueId()); + queuedMediaItemsBuilder.add(new QueuedMediaItem(mediaItem, queueItem.getQueueId())); } - return new QueueTimeline( - mediaItemsBuilder.build(), mediaItemToQueueIdMap.buildOrThrow(), /* fakeMediaItem= */ null); + return new QueueTimeline(queuedMediaItemsBuilder.build(), /* fakeMediaItem= */ null); + } + + /** Returns a copy of the current queue timeline. */ + public QueueTimeline copy() { + return new QueueTimeline(queuedMediaItems, fakeMediaItem); } /** @@ -91,9 +78,9 @@ import java.util.Map; * @return The corresponding queue ID or {@link QueueItem#UNKNOWN_ID} if not known. */ public long getQueueId(int mediaItemIndex) { - MediaItem mediaItem = getMediaItemAt(mediaItemIndex); - @Nullable Long queueId = mediaItemToQueueIdMap.get(mediaItem); - return queueId == null ? QueueItem.UNKNOWN_ID : queueId; + return mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size() + ? queuedMediaItems.get(mediaItemIndex).queueId + : QueueItem.UNKNOWN_ID; } /** @@ -103,7 +90,7 @@ import java.util.Map; * @return A new {@link QueueTimeline} reflecting the update. */ public QueueTimeline copyWithFakeMediaItem(@Nullable MediaItem fakeMediaItem) { - return new QueueTimeline(mediaItems, mediaItemToQueueIdMap, fakeMediaItem); + return new QueueTimeline(queuedMediaItems, fakeMediaItem); } /** @@ -115,23 +102,17 @@ import java.util.Map; */ public QueueTimeline copyWithNewMediaItem(int replaceIndex, MediaItem newMediaItem) { checkArgument( - replaceIndex < mediaItems.size() - || (replaceIndex == mediaItems.size() && fakeMediaItem != null)); - if (replaceIndex == mediaItems.size()) { - return new QueueTimeline(mediaItems, mediaItemToQueueIdMap, newMediaItem); + replaceIndex < queuedMediaItems.size() + || (replaceIndex == queuedMediaItems.size() && fakeMediaItem != null)); + if (replaceIndex == queuedMediaItems.size()) { + return new QueueTimeline(queuedMediaItems, newMediaItem); } - MediaItem oldMediaItem = mediaItems.get(replaceIndex); - // Create the new play list. - ImmutableList.Builder newMediaItemsBuilder = new ImmutableList.Builder<>(); - newMediaItemsBuilder.addAll(mediaItems.subList(0, replaceIndex)); - newMediaItemsBuilder.add(newMediaItem); - newMediaItemsBuilder.addAll(mediaItems.subList(replaceIndex + 1, mediaItems.size())); - // Update the map of items to queue IDs accordingly. - Map newMediaItemToQueueIdMap = new HashMap<>(mediaItemToQueueIdMap); - Long queueId = checkNotNull(newMediaItemToQueueIdMap.remove(oldMediaItem)); - newMediaItemToQueueIdMap.put(newMediaItem, queueId); - return new QueueTimeline( - newMediaItemsBuilder.build(), ImmutableMap.copyOf(newMediaItemToQueueIdMap), fakeMediaItem); + long queueId = queuedMediaItems.get(replaceIndex).queueId; + ImmutableList.Builder queuedItemsBuilder = new ImmutableList.Builder<>(); + queuedItemsBuilder.addAll(queuedMediaItems.subList(0, replaceIndex)); + queuedItemsBuilder.add(new QueuedMediaItem(newMediaItem, queueId)); + queuedItemsBuilder.addAll(queuedMediaItems.subList(replaceIndex + 1, queuedMediaItems.size())); + return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem); } /** @@ -143,11 +124,13 @@ import java.util.Map; * @return A new {@link QueueTimeline} reflecting the update. */ public QueueTimeline copyWithNewMediaItems(int index, List newMediaItems) { - ImmutableList.Builder newMediaItemsBuilder = new ImmutableList.Builder<>(); - newMediaItemsBuilder.addAll(mediaItems.subList(0, index)); - newMediaItemsBuilder.addAll(newMediaItems); - newMediaItemsBuilder.addAll(mediaItems.subList(index, mediaItems.size())); - return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem); + ImmutableList.Builder queuedItemsBuilder = new ImmutableList.Builder<>(); + queuedItemsBuilder.addAll(queuedMediaItems.subList(0, index)); + for (int i = 0; i < newMediaItems.size(); i++) { + queuedItemsBuilder.add(new QueuedMediaItem(newMediaItems.get(i), QueueItem.UNKNOWN_ID)); + } + queuedItemsBuilder.addAll(queuedMediaItems.subList(index, queuedMediaItems.size())); + return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem); } /** @@ -158,10 +141,10 @@ import java.util.Map; * @return A new {@link QueueTimeline} reflecting the update. */ public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) { - ImmutableList.Builder newMediaItemsBuilder = new ImmutableList.Builder<>(); - newMediaItemsBuilder.addAll(mediaItems.subList(0, fromIndex)); - newMediaItemsBuilder.addAll(mediaItems.subList(toIndex, mediaItems.size())); - return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem); + ImmutableList.Builder queuedItemsBuilder = new ImmutableList.Builder<>(); + queuedItemsBuilder.addAll(queuedMediaItems.subList(0, fromIndex)); + queuedItemsBuilder.addAll(queuedMediaItems.subList(toIndex, queuedMediaItems.size())); + return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem); } /** @@ -173,50 +156,45 @@ import java.util.Map; * @return A new {@link QueueTimeline} reflecting the update. */ public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) { - List list = new ArrayList<>(mediaItems); + List list = new ArrayList<>(queuedMediaItems); Util.moveItems(list, fromIndex, toIndex, newIndex); - return new QueueTimeline( - new ImmutableList.Builder().addAll(list).build(), - mediaItemToQueueIdMap, - fakeMediaItem); + return new QueueTimeline(ImmutableList.copyOf(list), fakeMediaItem); } - /** - * Returns the media item index of the given media item in the timeline, or {@link C#INDEX_UNSET} - * if the item is not part of this timeline. - * - * @param mediaItem The media item of interest. - * @return The index of the item or {@link C#INDEX_UNSET} if the item is not part of the timeline. - */ - public int indexOf(MediaItem mediaItem) { + /** Returns whether the timeline contains the given {@link MediaItem}. */ + public boolean contains(MediaItem mediaItem) { if (mediaItem.equals(fakeMediaItem)) { - return mediaItems.size(); + return true; } - int mediaItemIndex = mediaItems.indexOf(mediaItem); - return mediaItemIndex == -1 ? C.INDEX_UNSET : mediaItemIndex; + for (int i = 0; i < queuedMediaItems.size(); i++) { + if (mediaItem.equals(queuedMediaItems.get(i).mediaItem)) { + return true; + } + } + return false; } @Nullable public MediaItem getMediaItemAt(int mediaItemIndex) { - if (mediaItemIndex >= 0 && mediaItemIndex < mediaItems.size()) { - return mediaItems.get(mediaItemIndex); + if (mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()) { + return queuedMediaItems.get(mediaItemIndex).mediaItem; } - return (mediaItemIndex == mediaItems.size()) ? fakeMediaItem : null; + return (mediaItemIndex == queuedMediaItems.size()) ? fakeMediaItem : null; } @Override public int getWindowCount() { - return mediaItems.size() + ((fakeMediaItem == null) ? 0 : 1); + return queuedMediaItems.size() + ((fakeMediaItem == null) ? 0 : 1); } @Override public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { // TODO(b/149713425): Set duration if it's available from MediaMetadataCompat. MediaItem mediaItem; - if (windowIndex == mediaItems.size() && fakeMediaItem != null) { + if (windowIndex == queuedMediaItems.size() && fakeMediaItem != null) { mediaItem = fakeMediaItem; } else { - mediaItem = mediaItems.get(windowIndex); + mediaItem = queuedMediaItems.get(windowIndex).mediaItem; } return getWindow(window, mediaItem, windowIndex); } @@ -257,14 +235,13 @@ import java.util.Map; return false; } QueueTimeline other = (QueueTimeline) obj; - return Objects.equal(mediaItems, other.mediaItems) - && Objects.equal(mediaItemToQueueIdMap, other.mediaItemToQueueIdMap) + return Objects.equal(queuedMediaItems, other.queuedMediaItems) && Objects.equal(fakeMediaItem, other.fakeMediaItem); } @Override public int hashCode() { - return Objects.hashCode(mediaItems, mediaItemToQueueIdMap, fakeMediaItem); + return Objects.hashCode(queuedMediaItems, fakeMediaItem); } private static Window getWindow(Window window, MediaItem mediaItem, int windowIndex) { @@ -285,4 +262,35 @@ import java.util.Map; /* positionInFirstPeriodUs= */ 0); return window; } + + private static final class QueuedMediaItem { + + public final MediaItem mediaItem; + public final long queueId; + + public QueuedMediaItem(MediaItem mediaItem, long queueId) { + this.mediaItem = mediaItem; + this.queueId = queueId; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QueuedMediaItem)) { + return false; + } + QueuedMediaItem that = (QueuedMediaItem) o; + return queueId == that.queueId && mediaItem.equals(that.mediaItem); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + (int) (queueId ^ (queueId >>> 32)); + result = 31 * result + mediaItem.hashCode(); + return result; + } + } } diff --git a/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java b/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java index c514af10c8..de204146e6 100644 --- a/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java +++ b/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java @@ -123,6 +123,10 @@ public final class SessionCommand implements Bundleable { /** * The extra bundle of a custom command. It will be {@link Bundle#EMPTY} for a predefined command. + * + *

    Interoperability: This value is not used when the command is sent to a legacy {@link + * android.support.v4.media.session.MediaSessionCompat} or {@link + * android.support.v4.media.session.MediaControllerCompat}. */ public final Bundle customExtras; @@ -143,7 +147,9 @@ public final class SessionCommand implements Bundleable { * Creates a custom command. * * @param action The action of this custom command. - * @param extras An extra bundle for this custom command. + * @param extras An extra bundle for this custom command. This value is not used when the command + * is sent to a legacy {@link android.support.v4.media.session.MediaSessionCompat} or {@link + * android.support.v4.media.session.MediaControllerCompat}. */ public SessionCommand(String action, Bundle extras) { commandCode = COMMAND_CODE_CUSTOM; diff --git a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java index cd159ea0be..591dbaa653 100644 --- a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java @@ -36,6 +36,7 @@ import android.content.Context; import android.graphics.Bitmap; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; import android.os.Looper; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; @@ -785,6 +786,7 @@ public class DefaultMediaNotificationProviderTest { when(mockMediaSession.getPlayer()).thenReturn(mockPlayer); MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class); when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl); + when(mockMediaSessionImpl.getApplicationHandler()).thenReturn(new Handler(Looper.myLooper())); when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("https://example.test")); return mockMediaSession; } diff --git a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java new file mode 100644 index 0000000000..fbac60257a --- /dev/null +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023 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.session; + +import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; +import static com.google.common.truth.Truth8.assertThat; +import static java.util.Arrays.stream; + +import android.app.NotificationManager; +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.service.notification.StatusBarNotification; +import androidx.annotation.Nullable; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ServiceController; +import org.robolectric.shadows.ShadowLooper; + +@RunWith(AndroidJUnit4.class) +public class MediaSessionServiceTest { + + @Test + public void service_multipleSessionsOnMainThread_createsNotificationForEachSession() { + Context context = ApplicationProvider.getApplicationContext(); + ExoPlayer player1 = new TestExoPlayerBuilder(context).build(); + ExoPlayer player2 = new TestExoPlayerBuilder(context).build(); + MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); + MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); + TestService service = serviceController.create().get(); + service.setMediaNotificationProvider( + new DefaultMediaNotificationProvider( + service, + session -> 2000 + Integer.parseInt(session.getId()), + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID, + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID)); + + service.addSession(session1); + service.addSession(session2); + // Start the players so that we also create notifications for them. + player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + player1.prepare(); + player1.play(); + player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + player2.prepare(); + player2.play(); + ShadowLooper.idleMainLooper(); + + NotificationManager notificationService = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + assertThat( + stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId)) + .containsExactly(2001, 2002); + + serviceController.destroy(); + session1.release(); + session2.release(); + player1.release(); + player2.release(); + } + + @Test + public void service_multipleSessionsOnDifferentThreads_createsNotificationForEachSession() + throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + HandlerThread thread1 = new HandlerThread("player1"); + HandlerThread thread2 = new HandlerThread("player2"); + thread1.start(); + thread2.start(); + ExoPlayer player1 = new TestExoPlayerBuilder(context).setLooper(thread1.getLooper()).build(); + ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build(); + MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); + MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); + TestService service = serviceController.create().get(); + service.setMediaNotificationProvider( + new DefaultMediaNotificationProvider( + service, + session -> 2000 + Integer.parseInt(session.getId()), + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID, + DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID)); + NotificationManager notificationService = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + service.addSession(session1); + service.addSession(session2); + // Start the players so that we also create notifications for them. + new Handler(thread1.getLooper()) + .post( + () -> { + player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + player1.prepare(); + player1.play(); + }); + new Handler(thread2.getLooper()) + .post( + () -> { + player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + player2.prepare(); + player2.play(); + }); + runMainLooperUntil(() -> notificationService.getActiveNotifications().length == 2); + + assertThat( + stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId)) + .containsExactly(2001, 2002); + + serviceController.destroy(); + session1.release(); + session2.release(); + new Handler(thread1.getLooper()).post(player1::release); + new Handler(thread2.getLooper()).post(player2::release); + thread1.quit(); + thread2.quit(); + } + + private static final class TestService extends MediaSessionService { + @Nullable + @Override + public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { + return null; // No need to support binding or pending intents for this test. + } + } +} diff --git a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java index b7c70c4825..a49212028f 100644 --- a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java +++ b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java @@ -103,6 +103,7 @@ public class CommonConstants { public static final String KEY_MAX_SEEK_TO_PREVIOUS_POSITION_MS = "maxSeekToPreviousPositionMs"; public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters"; public static final String KEY_CURRENT_TRACKS = "currentTracks"; + public static final String KEY_AVAILABLE_COMMANDS = "availableCommands"; // SessionCompat arguments public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken"; diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java index 71543ae0fe..16fec985e2 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java @@ -980,6 +980,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { assertThat(TestUtils.equals(receivedSessionExtras.get(1), sessionExtras)).isTrue(); } + @Test + public void broadcastCustomCommand_cnSessionEventCalled() throws Exception { + Bundle commandCallExtras = new Bundle(); + commandCallExtras.putString("key-0", "value-0"); + // Specify session command extras to see that they are NOT used. + Bundle sessionCommandExtras = new Bundle(); + sessionCommandExtras.putString("key-0", "value-1"); + SessionCommand sessionCommand = new SessionCommand("custom_action", sessionCommandExtras); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedCommand = new AtomicReference<>(); + AtomicReference receivedCommandExtras = new AtomicReference<>(); + MediaControllerCompat.Callback callback = + new MediaControllerCompat.Callback() { + @Override + public void onSessionEvent(String event, Bundle extras) { + receivedCommand.set(event); + receivedCommandExtras.set(extras); + latch.countDown(); + } + }; + controllerCompat.registerCallback(callback, handler); + + session.broadcastCustomCommand(sessionCommand, commandCallExtras); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedCommand.get()).isEqualTo("custom_action"); + assertThat(TestUtils.equals(receivedCommandExtras.get(), commandCallExtras)).isTrue(); + } + @Test public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion() throws Exception { @@ -1056,8 +1085,9 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { } @Test - public void onMediaMetadataChanged_updatesLegacyMetadata_correctModelConversion() - throws Exception { + public void + onMediaMetadataChanged_withGetMetadataAndGetCurrentMediaItemCommand_updatesLegacyMetadata() + throws Exception { int testItemIndex = 3; String testDisplayTitle = "displayTitle"; long testDurationMs = 30_000; @@ -1071,6 +1101,12 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { .setMediaId(testMediaItems.get(testItemIndex).mediaId) .setMediaMetadata(testMediaMetadata) .build()); + session + .getMockPlayer() + .notifyAvailableCommandsChanged( + new Player.Commands.Builder() + .addAll(Player.COMMAND_GET_METADATA, Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + .build()); session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems)); session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex); session.getMockPlayer().setDuration(testDurationMs); @@ -1102,6 +1138,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId); } + @Test + public void onMediaMetadataChanged_withGetMetadataCommandOnly_updatesLegacyMetadata() + throws Exception { + int testItemIndex = 3; + String testDisplayTitle = "displayTitle"; + List testMediaItems = MediaTestUtils.createMediaItems(/* size= */ 5); + MediaMetadata testMediaMetadata = + new MediaMetadata.Builder().setTitle(testDisplayTitle).build(); + testMediaItems.set( + testItemIndex, + new MediaItem.Builder() + .setMediaId(testMediaItems.get(testItemIndex).mediaId) + .setMediaMetadata(testMediaMetadata) + .build()); + session + .getMockPlayer() + .notifyAvailableCommandsChanged( + new Player.Commands.Builder().add(Player.COMMAND_GET_METADATA).build()); + session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems)); + session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex); + AtomicReference metadataRef = new AtomicReference<>(); + CountDownLatch latchForMetadata = new CountDownLatch(1); + MediaControllerCompat.Callback callback = + new MediaControllerCompat.Callback() { + @Override + public void onMetadataChanged(MediaMetadataCompat metadata) { + metadataRef.set(metadata); + latchForMetadata.countDown(); + } + }; + controllerCompat.registerCallback(callback, handler); + + session.getMockPlayer().notifyMediaMetadataChanged(testMediaMetadata); + + assertThat(latchForMetadata.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaMetadataCompat parameterMetadataCompat = metadataRef.get(); + MediaMetadataCompat getterMetadataCompat = controllerCompat.getMetadata(); + assertThat(parameterMetadataCompat.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)) + .isEqualTo(testDisplayTitle); + assertThat(getterMetadataCompat.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE)) + .isEqualTo(testDisplayTitle); + } + @Test public void playlistChange() throws Exception { AtomicReference> queueRef = new AtomicReference<>(); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerTest.java index 0beb95bef4..a81ef25d51 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerTest.java @@ -326,6 +326,8 @@ public class MediaControllerListenerTest { @Player.RepeatMode int testRepeatMode = Player.REPEAT_MODE_ALL; int testCurrentAdGroupIndex = 33; int testCurrentAdIndexInAdGroup = 11; + Commands testCommands = + new Commands.Builder().addAllCommands().remove(Player.COMMAND_STOP).build(); AtomicInteger stateRef = new AtomicInteger(); AtomicReference timelineRef = new AtomicReference<>(); AtomicReference playlistMetadataRef = new AtomicReference<>(); @@ -335,7 +337,8 @@ public class MediaControllerListenerTest { AtomicInteger currentAdIndexInAdGroupRef = new AtomicInteger(); AtomicBoolean shuffleModeEnabledRef = new AtomicBoolean(); AtomicInteger repeatModeRef = new AtomicInteger(); - CountDownLatch latch = new CountDownLatch(7); + AtomicReference commandsRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(8); MediaController controller = controllerTestRule.createController(remoteSession.getToken()); threadTestRule .getHandler() @@ -343,6 +346,12 @@ public class MediaControllerListenerTest { () -> controller.addListener( new Player.Listener() { + @Override + public void onAvailableCommandsChanged(Commands availableCommands) { + commandsRef.set(availableCommands); + latch.countDown(); + } + @Override public void onAudioAttributesChanged(AudioAttributes attributes) { audioAttributesRef.set(attributes); @@ -402,6 +411,7 @@ public class MediaControllerListenerTest { .setIsPlayingAd(true) .setCurrentAdGroupIndex(testCurrentAdGroupIndex) .setCurrentAdIndexInAdGroup(testCurrentAdIndexInAdGroup) + .setAvailableCommands(testCommands) .build(); remoteSession.setPlayer(playerConfig); @@ -415,6 +425,7 @@ public class MediaControllerListenerTest { assertThat(currentAdIndexInAdGroupRef.get()).isEqualTo(testCurrentAdIndexInAdGroup); assertThat(shuffleModeEnabledRef.get()).isEqualTo(testShuffleModeEnabled); assertThat(repeatModeRef.get()).isEqualTo(testRepeatMode); + assertThat(commandsRef.get()).isEqualTo(testCommands); } @Test @@ -1084,15 +1095,16 @@ public class MediaControllerListenerTest { assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(initialCurrentTracksRef.get()).isEqualTo(Tracks.EMPTY); - assertThat(changedCurrentTracksFromParamRef.get()).isEqualTo(currentTracks); - assertThat(changedCurrentTracksFromGetterRef.get()).isEqualTo(currentTracks); + assertThat(changedCurrentTracksFromParamRef.get().getGroups()).hasSize(2); + assertThat(changedCurrentTracksFromGetterRef.get()) + .isEqualTo(changedCurrentTracksFromParamRef.get()); assertThat(capturedEvents).hasSize(2); assertThat(getEventsAsList(capturedEvents.get(0))).containsExactly(Player.EVENT_TRACKS_CHANGED); assertThat(getEventsAsList(capturedEvents.get(1))) .containsExactly(Player.EVENT_IS_LOADING_CHANGED); assertThat(changedCurrentTracksFromOnEvents).hasSize(2); - assertThat(changedCurrentTracksFromOnEvents.get(0)).isEqualTo(currentTracks); - assertThat(changedCurrentTracksFromOnEvents.get(1)).isEqualTo(currentTracks); + assertThat(changedCurrentTracksFromOnEvents.get(0).getGroups()).hasSize(2); + assertThat(changedCurrentTracksFromOnEvents.get(1).getGroups()).hasSize(2); // Assert that an equal instance is not re-sent over the binder. assertThat(changedCurrentTracksFromOnEvents.get(0)) .isSameInstanceAs(changedCurrentTracksFromOnEvents.get(1)); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java index 0bf910b686..4a4c01db4f 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java @@ -42,6 +42,7 @@ import androidx.media3.common.IllegalSeekPositionException; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.MediaMetadata; +import androidx.media3.common.Metadata; import androidx.media3.common.PlaybackException; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.Player; @@ -50,6 +51,7 @@ import androidx.media3.common.Rating; import androidx.media3.common.StarRating; import androidx.media3.common.Timeline; import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; @@ -427,7 +429,7 @@ public class MediaControllerTest { assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs); assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs); assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters); - assertThat(currentTracksRef.get()).isEqualTo(currentTracks); + assertThat(currentTracksRef.get().getGroups()).hasSize(2); assertTimelineMediaItemsEquals(timelineRef.get(), timeline); assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex); assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem); @@ -1211,6 +1213,118 @@ public class MediaControllerTest { assertThat(mediaMetadata).isEqualTo(testMediaMetadata); } + @Test + public void getCurrentTracks_hasEqualTrackGroupsForEqualGroupsInPlayer() throws Exception { + // Include metadata in Format to ensure the track group can't be fully bundled. + Tracks initialPlayerTracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup( + new Format.Builder().setMetadata(new Metadata()).setId("1").build()), + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[1], + /* trackSelected= */ new boolean[1]), + new Tracks.Group( + new TrackGroup( + new Format.Builder().setMetadata(new Metadata()).setId("2").build()), + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[1], + /* trackSelected= */ new boolean[1]))); + Tracks updatedPlayerTracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup( + new Format.Builder().setMetadata(new Metadata()).setId("2").build()), + /* adaptiveSupported= */ true, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true}), + new Tracks.Group( + new TrackGroup( + new Format.Builder().setMetadata(new Metadata()).setId("3").build()), + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[1], + /* trackSelected= */ new boolean[1]))); + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setCurrentTracks(initialPlayerTracks) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch trackChangedEvent = new CountDownLatch(1); + threadTestRule + .getHandler() + .postAndSync( + () -> + controller.addListener( + new Player.Listener() { + @Override + public void onTracksChanged(Tracks tracks) { + trackChangedEvent.countDown(); + } + })); + + Tracks initialControllerTracks = + threadTestRule.getHandler().postAndSync(controller::getCurrentTracks); + // Do something unrelated first to ensure tracks are correctly kept even after multiple updates. + remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_READY); + remoteSession.getMockPlayer().notifyTracksChanged(updatedPlayerTracks); + trackChangedEvent.await(); + Tracks updatedControllerTracks = + threadTestRule.getHandler().postAndSync(controller::getCurrentTracks); + + assertThat(initialControllerTracks.getGroups()).hasSize(2); + assertThat(updatedControllerTracks.getGroups()).hasSize(2); + assertThat(initialControllerTracks.getGroups().get(1).getMediaTrackGroup()) + .isEqualTo(updatedControllerTracks.getGroups().get(0).getMediaTrackGroup()); + } + + @Test + public void getCurrentTracksAndTrackOverrides_haveEqualTrackGroupsForEqualGroupsInPlayer() + throws Exception { + // Include metadata in Format to ensure the track group can't be fully bundled. + TrackGroup playerTrackGroupForOverride = + new TrackGroup(new Format.Builder().setMetadata(new Metadata()).setId("2").build()); + Tracks playerTracks = + new Tracks( + ImmutableList.of( + new Tracks.Group( + new TrackGroup( + new Format.Builder().setMetadata(new Metadata()).setId("1").build()), + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[1], + /* trackSelected= */ new boolean[1]), + new Tracks.Group( + playerTrackGroupForOverride, + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[1], + /* trackSelected= */ new boolean[1]))); + TrackSelectionParameters trackSelectionParameters = + TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + .buildUpon() + .addOverride( + new TrackSelectionOverride(playerTrackGroupForOverride, /* trackIndex= */ 0)) + .build(); + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setCurrentTracks(playerTracks) + .setTrackSelectionParameters(trackSelectionParameters) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + + Tracks controllerTracks = threadTestRule.getHandler().postAndSync(controller::getCurrentTracks); + TrackSelectionParameters controllerTrackSelectionParameters = + threadTestRule.getHandler().postAndSync(controller::getTrackSelectionParameters); + + TrackGroup controllerTrackGroup = controllerTracks.getGroups().get(1).getMediaTrackGroup(); + assertThat(controllerTrackSelectionParameters.overrides) + .containsExactly( + controllerTrackGroup, + new TrackSelectionOverride(controllerTrackGroup, /* trackIndex= */ 0)); + } + @Test public void setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly() diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerWithMediaSessionCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerWithMediaSessionCompatTest.java index 748fbf9fd4..8e4afdcdeb 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerWithMediaSessionCompatTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerWithMediaSessionCompatTest.java @@ -78,6 +78,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.truth.os.BundleSubject; import androidx.test.filters.MediumTest; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; import com.google.common.collect.Range; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -414,6 +415,41 @@ public class MediaControllerWithMediaSessionCompatTest { assertThat(timelineRef.get().getPeriodCount()).isEqualTo(0); } + @Test + public void setQueue_withDuplicatedMediaItems_updatesAndNotifiesTimeline() throws Exception { + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + AtomicInteger reasonRef = new AtomicInteger(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + reasonRef.set(reason); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + + List mediaItems = MediaTestUtils.createMediaItems(/* size= */ 2); + Timeline testTimeline = + MediaTestUtils.createTimeline( + ImmutableList.copyOf(Iterables.concat(mediaItems, mediaItems))); + List testQueue = + MediaTestUtils.convertToQueueItemsWithoutBitmap( + MediaUtils.convertToMediaItemList(testTimeline)); + session.setQueue(testQueue); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertMediaIdEquals(testTimeline, timelineFromParamRef.get()); + MediaTestUtils.assertMediaIdEquals(testTimeline, timelineFromGetterRef.get()); + assertThat(reasonRef.get()).isEqualTo(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + } + @Test public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception { CountDownLatch latch = new CountDownLatch(1); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionAndControllerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionAndControllerTest.java index c4def47aad..172f6a8dfa 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionAndControllerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionAndControllerTest.java @@ -24,13 +24,21 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.Context; import android.os.Handler; import android.os.Looper; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.Metadata; +import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; +import androidx.media3.common.Tracks; import androidx.media3.common.util.Util; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.MainLooperTestRule; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.ListenableFuture; import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; @@ -248,4 +256,58 @@ public class MediaSessionAndControllerTest { player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); } + + @Test + public void setTrackSelectionParameters_withOverrides_matchesExpectedTrackGroupInPlayer() + throws Exception { + MockPlayer player = + new MockPlayer.Builder().setApplicationLooper(Looper.getMainLooper()).build(); + // Intentionally add metadata to the format as this can't be bundled. + Tracks.Group trackGroupInPlayer = + new Tracks.Group( + new TrackGroup( + new Format.Builder() + .setId("0") + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setMetadata(new Metadata()) + .build(), + new Format.Builder() + .setId("1") + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setMetadata(new Metadata()) + .build()), + /* adaptiveSupported= */ false, + /* trackSupport= */ new int[] {C.FORMAT_HANDLED, C.FORMAT_HANDLED}, + /* trackSelected= */ new boolean[] {true, false}); + player.currentTracks = new Tracks(ImmutableList.of(trackGroupInPlayer)); + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setId(TAG).build()); + MediaController controller = controllerTestRule.createController(session.getToken()); + + threadTestRule + .getHandler() + .postAndSync( + () -> + controller.setTrackSelectionParameters( + controller + .getTrackSelectionParameters() + .buildUpon() + .setOverrideForType( + new TrackSelectionOverride( + controller + .getCurrentTracks() + .getGroups() + .get(0) + .getMediaTrackGroup(), + /* trackIndex= */ 1)) + .build())); + player.awaitMethodCalled(MockPlayer.METHOD_SET_TRACK_SELECTION_PARAMETERS, TIMEOUT_MS); + + assertThat(player.trackSelectionParameters.overrides) + .containsExactly( + trackGroupInPlayer.getMediaTrackGroup(), + new TrackSelectionOverride( + trackGroupInPlayer.getMediaTrackGroup(), /* trackIndex= */ 1)); + } } diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java index d48253eed7..f70c6396d6 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java @@ -18,6 +18,7 @@ package androidx.media3.session; import static androidx.media3.common.Player.COMMAND_GET_TRACKS; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION; import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; +import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION; import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION; @@ -397,6 +398,10 @@ public class MediaSessionProviderService extends Service { player.trackSelectionParameters = TrackSelectionParameters.fromBundle(trackSelectionParametersBundle); } + @Nullable Bundle availableCommandsBundle = config.getBundle(KEY_AVAILABLE_COMMANDS); + if (availableCommandsBundle != null) { + player.commands = Player.Commands.CREATOR.fromBundle(availableCommandsBundle); + } return player; } diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java index ca2840480d..dff8fc65c8 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaSession.java @@ -17,6 +17,7 @@ package androidx.media3.session; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION; import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; +import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION; import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION; @@ -742,6 +743,12 @@ public class RemoteMediaSession { return this; } + @CanIgnoreReturnValue + public MockPlayerConfigBuilder setAvailableCommands(Player.Commands availableCommands) { + bundle.putBundle(KEY_AVAILABLE_COMMANDS, availableCommands.toBundle()); + return this; + } + public Bundle build() { return bundle; } diff --git a/libraries/test_utils/build.gradle b/libraries/test_utils/build.gradle index d3901cf175..239104ad53 100644 --- a/libraries/test_utils/build.gradle +++ b/libraries/test_utils/build.gradle @@ -20,6 +20,7 @@ dependencies { api 'androidx.test.ext:truth:' + androidxTestTruthVersion api 'junit:junit:' + junitVersion api 'com.google.truth:truth:' + truthVersion + api 'com.google.truth.extensions:truth-java8-extension:' + truthVersion compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion diff --git a/missing_aar_type_workaround.gradle b/missing_aar_type_workaround.gradle index b0b2fe7035..5aa71111c3 100644 --- a/missing_aar_type_workaround.gradle +++ b/missing_aar_type_workaround.gradle @@ -24,6 +24,7 @@ def addMissingAarTypeToXml(xml) { "com.google.ads.interactivemedia.v3:interactivemedia", "com.google.guava:guava", "com.google.truth:truth", + "com.google.truth.extensions:truth-java8-extension", "com.squareup.okhttp3:okhttp", "com.squareup.okhttp3:mockwebserver", "org.mockito:mockito-core", @@ -77,6 +78,11 @@ def addMissingAarTypeToXml(xml) { (isProjectLibrary || aar_dependencies.contains(dependencyName)) if (!hasJar && !hasAar) { + // To look for what kind of dependency it is i.e. aar or jar type, + // please expand the External Libraries in Project view in Android Studio + // and search for your dependency inside Gradle Script dependencies. + // .aar files have @aar suffix at the end of their name, + // while .jar files have nothing. throw new IllegalStateException( dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle") }