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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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
*
- *
+ *
*
*
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 instances must be accessed from a single application thread unless indicated
@@ -158,6 +159,8 @@ import java.util.List;
* may use background threads to load data. These are implementation specific.
*
*/
+// 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.
*
- *
+ *
*/
+// 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:
+ *
+ *
+ * - Ensuring the {@link
+ * DrmSessionManager#acquireSession(DrmSessionEventListener.EventDispatcher, Format)} call
+ * will trigger a callback to {@link DrmSessionEventListener} (e.g. it will load new keys).
+ * If not, this method will block forever.
+ *
- Releasing the returned {@link DrmSession} instance (on {@link #handlerThread}).
+ *
- Releasing {@link #drmSessionManager} if a {@link DrmSession} instance is returned (the
+ * manager will be released before an exception is thrown).
+ *
+ */
+ 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")
}