Merge pull request #336 from androidx/release-1.0.1-stable

1.0.1
This commit is contained in:
Rohit Kumar Singh 2023-04-19 18:08:17 +01:00 committed by GitHub
commit 3c01488f8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1989 additions and 512 deletions

View file

@ -20,6 +20,7 @@ body:
label: Media3 Version label: Media3 Version
description: What version of Media3 (or ExoPlayer) are you using? description: What version of Media3 (or ExoPlayer) are you using?
options: options:
- Media3 1.0.1
- Media3 1.0.0 - Media3 1.0.0
- Media3 1.0.0-rc02 - Media3 1.0.0-rc02
- Media3 1.0.0-rc01 - Media3 1.0.0-rc01
@ -29,6 +30,7 @@ body:
- Media3 1.0.0-alpha03 - Media3 1.0.0-alpha03
- Media3 1.0.0-alpha02 - Media3 1.0.0-alpha02
- Media3 1.0.0-alpha01 - Media3 1.0.0-alpha01
- ExoPlayer 2.18.6
- ExoPlayer 2.18.5 - ExoPlayer 2.18.5
- ExoPlayer 2.18.4 - ExoPlayer 2.18.4
- ExoPlayer 2.18.3 - ExoPlayer 2.18.3

View file

@ -36,7 +36,7 @@ In case your question is related to a piece of media:
- Authentication HTTP headers - Authentication HTTP headers
Don't forget to check ExoPlayer's supported formats and devices, if applicable 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, 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 then email the link/bug report to dev.exoplayer@gmail.com using a subject in the

View file

@ -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 into the `main` branch. Before a pull request can be accepted you must submit
a Contributor License Agreement, as described below. 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 ## Contributor license agreement
Contributions to any Google project must be accompanied by a Contributor Contributions to any Google project must be accompanied by a Contributor

View file

@ -1,5 +1,44 @@
# Release notes # 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) ### 1.0.0 (2023-03-22)
This release corresponds to the This release corresponds to the

View file

@ -12,8 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
project.ext { project.ext {
releaseVersion = '1.0.0' releaseVersion = '1.0.1'
releaseVersionCode = 1_000_000_3_00 releaseVersionCode = 1_000_001_3_00
minSdkVersion = 16 minSdkVersion = 16
appTargetSdkVersion = 33 appTargetSdkVersion = 33
// API version before restricting local file access. // API version before restricting local file access.

View file

@ -27,6 +27,7 @@ android {
versionCode project.ext.releaseVersionCode versionCode project.ext.releaseVersionCode
minSdkVersion project.ext.minSdkVersion minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.appTargetSdkVersion targetSdkVersion project.ext.appTargetSdkVersion
multiDexEnabled true
} }
buildTypes { buildTypes {

View file

@ -21,7 +21,7 @@
<string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string> <string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string>
<string name="error_cleartext_not_permitted">Cleartext HTTP traffic not permitted. See https://exoplayer.dev/issues/cleartext-not-permitted</string> <string name="error_cleartext_not_permitted">Cleartext HTTP traffic not permitted. See https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted</string>
<string name="error_generic">Playback failed</string> <string name="error_generic">Playback failed</string>

View file

@ -26,6 +26,7 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.ListView import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@ -73,20 +74,24 @@ class MainActivity : AppCompatActivity() {
val intent = Intent(this, PlayerActivity::class.java) val intent = Intent(this, PlayerActivity::class.java)
startActivity(intent) startActivity(intent)
} }
onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(/* enabled= */ true) {
override fun handleOnBackPressed() {
popPathStack()
}
}
)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) { if (item.itemId == android.R.id.home) {
onBackPressed() onBackPressedDispatcher.onBackPressed()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
override fun onBackPressed() {
popPathStack()
}
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
initializeBrowser() initializeBrowser()

View file

@ -135,6 +135,7 @@ class PlayerActivity : AppCompatActivity() {
updateMediaMetadataUI(controller.mediaMetadata) updateMediaMetadataUI(controller.mediaMetadata)
updateShuffleSwitchUI(controller.shuffleModeEnabled) updateShuffleSwitchUI(controller.shuffleModeEnabled)
updateRepeatSwitchUI(controller.repeatMode) updateRepeatSwitchUI(controller.repeatMode)
playerView.setShowSubtitleButton(controller.currentTracks.isTypeSupported(TRACK_TYPE_TEXT))
controller.addListener( controller.addListener(
object : Player.Listener { object : Player.Listener {

View file

@ -61,6 +61,6 @@ manual steps.
(this will only appear if the AAR is present), then build and run the demo (this will only appear if the AAR is present), then build and run the demo
app and select a MediaPipe-based effect. 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/ [MediaPipe]: https://google.github.io/mediapipe/
[build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html [build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html

View file

@ -28,7 +28,7 @@ import androidx.media3.effect.MatrixTransformation;
*/ */
/* package */ final class MatrixTransformationFactory { /* 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 * #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. * linearly in size from a single point to filling the full output frame.
*/ */

View file

@ -52,12 +52,12 @@ public final class AuxEffectInfo {
* Creates an instance with the given effect identifier and send level. * Creates an instance with the given effect identifier and send level.
* *
* @param effectId The effect identifier. This is the value returned by {@link * @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 * effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying
* audio track. * audio track.
* @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1 * @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 * is full send. If {@code effectId} is not {@link #NO_AUX_EFFECT_ID}, this value is passed to
* to {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track. * {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track.
*/ */
public AuxEffectInfo(int effectId, float sendLevel) { public AuxEffectInfo(int effectId, float sendLevel) {
this.effectId = effectId; this.effectId = effectId;

View file

@ -35,7 +35,8 @@ import java.util.UUID;
* *
* <p>When building formats, populate all fields whose values are known and relevant to the type of * <p>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 <a * format being constructed. For information about different types of format, see ExoPlayer's <a
* href="https://exoplayer.dev/supported-formats.html">Supported formats page</a>. * href="https://developer.android.com/guide/topics/media/exoplayer/supported-formats">Supported
* formats page</a>.
* *
* <h2>Fields commonly relevant to all formats</h2> * <h2>Fields commonly relevant to all formats</h2>
* *

View file

@ -20,6 +20,7 @@ import android.view.Surface;
import android.view.SurfaceHolder; import android.view.SurfaceHolder;
import android.view.SurfaceView; import android.view.SurfaceView;
import android.view.TextureView; import android.view.TextureView;
import androidx.annotation.CallSuper;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.text.Cue; import androidx.media3.common.text.Cue;
import androidx.media3.common.text.CueGroup; import androidx.media3.common.text.CueGroup;
@ -47,14 +48,29 @@ public class ForwardingPlayer implements Player {
return player.getApplicationLooper(); return player.getApplicationLooper();
} }
/** Calls {@link Player#addListener(Listener)} on the delegate. */ /**
* Calls {@link Player#addListener(Listener)} on the delegate.
*
* <p>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 @Override
@CallSuper
public void addListener(Listener listener) { public void addListener(Listener listener) {
player.addListener(new ForwardingListener(this, listener)); player.addListener(new ForwardingListener(this, listener));
} }
/** Calls {@link Player#removeListener(Listener)} on the delegate. */ /**
* Calls {@link Player#removeListener(Listener)} on the delegate.
*
* <p>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 @Override
@CallSuper
public void removeListener(Listener listener) { public void removeListener(Listener listener) {
player.removeListener(new ForwardingListener(this, listener)); player.removeListener(new ForwardingListener(this, listener));
} }

View file

@ -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". */ /** 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. // 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}. */ /** The version of the library expressed as {@code TAG + "/" + VERSION}. */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. // 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. * 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). * (123-045-006-3-00).
*/ */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. // 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. */ /** Whether the library was compiled with {@link Assertions} checks enabled. */
public static final boolean ASSERTIONS_ENABLED = true; public static final boolean ASSERTIONS_ENABLED = true;

View file

@ -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 * 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. * https://) when the app's Network Security Configuration does not permit it.
* *
* <p>See <a href="https://exoplayer.dev/issues/cleartext-not-permitted">this corresponding * <p>See <a
* troubleshooting topic</a>. * href="https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted">this
* corresponding troubleshooting topic</a>.
*/ */
public static final int ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED = 2007; public static final int ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED = 2007;
/** Caused by reading data out of the data bound. */ /** Caused by reading data out of the data bound. */

View file

@ -3355,7 +3355,8 @@ public abstract class SimpleBasePlayer extends BasePlayer {
"Player is accessed on the wrong thread.\n" "Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n" + "Current thread: '%s'\n"
+ "Expected 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()); Thread.currentThread().getName(), applicationLooper.getThread().getName());
throw new IllegalStateException(message); throw new IllegalStateException(message);
} }

View file

@ -61,8 +61,9 @@ import java.util.List;
* *
* <h2 id="single-file">Single media file or on-demand stream</h2> * <h2 id="single-file">Single media file or on-demand stream</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-single-file.svg" alt="Example timeline for a * <p style="align:center"><img
* single file"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-single-file.svg"
* alt="Example timeline for a single file">
* *
* <p>A timeline for a single media file or on-demand stream consists of a single period and window. * <p>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 * The window spans the whole period, indicating that all parts of the media are available for
@ -71,8 +72,9 @@ import java.util.List;
* *
* <h2>Playlist of media files or on-demand streams</h2> * <h2>Playlist of media files or on-demand streams</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-playlist.svg" alt="Example timeline for a * <p style="align:center"><img
* playlist of files"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-playlist.svg"
* alt="Example timeline for a playlist of files">
* *
* <p>A timeline for a playlist of media files or on-demand streams consists of multiple periods, * <p>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 * each with its own window. Each window spans the whole of the corresponding period, and typically
@ -82,8 +84,9 @@ import java.util.List;
* *
* <h2 id="live-limited">Live stream with limited availability</h2> * <h2 id="live-limited">Live stream with limited availability</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-live-limited.svg" alt="Example timeline for * <p style="align:center"><img
* a live stream with limited availability"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-limited.svg"
* alt="Example timeline for a live stream with limited availability">
* *
* <p>A timeline for a live stream consists of a period whose duration is unknown, since it's * <p>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 * continually extending as more content is broadcast. If content only remains available for a
@ -95,8 +98,9 @@ import java.util.List;
* *
* <h2>Live stream with indefinite availability</h2> * <h2>Live stream with indefinite availability</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-live-indefinite.svg" alt="Example timeline * <p style="align:center"><img
* for a live stream with indefinite availability"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-indefinite.svg"
* alt="Example timeline for a live stream with indefinite availability">
* *
* <p>A timeline for a live stream with indefinite availability is similar to the <a * <p>A timeline for a live stream with indefinite availability is similar to the <a
* href="#live-limited">Live stream with limited availability</a> case, except that the window * href="#live-limited">Live stream with limited availability</a> case, except that the window
@ -105,8 +109,9 @@ import java.util.List;
* *
* <h2 id="live-multi-period">Live stream with multiple periods</h2> * <h2 id="live-multi-period">Live stream with multiple periods</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-live-multi-period.svg" alt="Example timeline * <p style="align:center"><img
* for a live stream with multiple periods"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-multi-period.svg"
* alt="Example timeline for a live stream with multiple periods">
* *
* <p>This case arises when a live stream is explicitly divided into separate periods, for example * <p>This case arises when a live stream is explicitly divided into separate periods, for example
* at content boundaries. This case is similar to the <a href="#live-limited">Live stream with * at content boundaries. This case is similar to the <a href="#live-limited">Live stream with
@ -115,8 +120,9 @@ import java.util.List;
* *
* <h2>On-demand stream followed by live stream</h2> * <h2>On-demand stream followed by live stream</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-advanced.svg" alt="Example timeline for an * <p style="align:center"><img
* on-demand stream followed by a live stream"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-advanced.svg"
* alt="Example timeline for an on-demand stream followed by a live stream">
* *
* <p>This case is the concatenation of the <a href="#single-file">Single media file or on-demand * <p>This case is the concatenation of the <a href="#single-file">Single media file or on-demand
* stream</a> and <a href="#multi-period">Live stream with multiple periods</a> cases. When playback * stream</a> and <a href="#multi-period">Live stream with multiple periods</a> cases. When playback
@ -125,12 +131,15 @@ import java.util.List;
* *
* <h2 id="single-file-midrolls">On-demand stream with mid-roll ads</h2> * <h2 id="single-file-midrolls">On-demand stream with mid-roll ads</h2>
* *
* <p style="align:center"><img src="doc-files/timeline-single-file-midrolls.svg" alt="Example * <p style="align:center"><img
* timeline for an on-demand stream with mid-roll ad groups"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-single-file-midrolls.svg"
* alt="Example timeline for an on-demand stream with mid-roll ad groups">
* *
* <p>This case includes mid-roll ad groups, which are defined as part of the timeline's single * <p>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. * 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 { public abstract class Timeline implements Bundleable {
/** /**

View file

@ -191,6 +191,18 @@ public final class Tracks implements Bundleable {
return mediaTrackGroup.type; 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 @Override
public boolean equals(@Nullable Object other) { public boolean equals(@Nullable Object other) {
if (this == other) { if (this == other) {

View file

@ -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.
* *
* <p>If {@code true}, the device supports a protected output path for DRM content when using GL. * <p>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.
* *
* <p>This extension allows passing {@link EGL14#EGL_NO_SURFACE} for both the write and read * <p>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, * 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.
* *
* <p>This extension allows sampling raw YUV values from an external texture, which is required * <p>This extension allows sampling raw YUV values from an external texture, which is required
* for HDR. * for HDR.

View file

@ -375,8 +375,8 @@ public interface HttpDataSource extends DataSource {
/** /**
* Thrown when cleartext HTTP traffic is not permitted. For more information including how to * Thrown when cleartext HTTP traffic is not permitted. For more information including how to
* enable cleartext traffic, see the <a * enable cleartext traffic, see the <a
* href="https://exoplayer.dev/issues/cleartext-not-permitted">corresponding troubleshooting * href="https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted">corresponding
* topic</a>. * troubleshooting topic</a>.
*/ */
final class CleartextNotPermittedException extends HttpDataSourceException { final class CleartextNotPermittedException extends HttpDataSourceException {
@ -384,7 +384,7 @@ public interface HttpDataSource extends DataSource {
public CleartextNotPermittedException(IOException cause, DataSpec dataSpec) { public CleartextNotPermittedException(IOException cause, DataSpec dataSpec) {
super( super(
"Cleartext HTTP traffic not permitted. See" "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, cause,
dataSpec, dataSpec,
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,

View file

@ -128,6 +128,4 @@ GL rendering mode has better performance, so should be preferred
* [Troubleshooting using decoding extensions][] * [Troubleshooting using decoding extensions][]
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
[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 [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

View file

@ -115,12 +115,10 @@ then implement your own logic to use the renderer for a given track.
[top level README]: ../../README.md [top level README]: ../../README.md
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html [Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
[ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781 [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 ## Links
* [Troubleshooting using decoding extensions][] * [Troubleshooting using decoding extensions][]
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
[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 [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

View file

@ -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][]
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
[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 [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

View file

@ -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][]
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
[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 [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

View file

@ -141,6 +141,4 @@ GL rendering mode has better performance, so should be preferred.
* [Troubleshooting using decoding extensions][] * [Troubleshooting using decoding extensions][]
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
[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 [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

View file

@ -128,8 +128,9 @@ import java.util.List;
* *
* <p>The figure below shows ExoPlayer's threading model. * <p>The figure below shows ExoPlayer's threading model.
* *
* <p style="align:center"><img src="doc-files/exoplayer-threading-model.svg" alt="ExoPlayer's * <p style="align:center"><img
* threading model"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/exoplayer-threading-model.svg"
* alt="ExoPlayer's threading model">
* *
* <ul> * <ul>
* <li>ExoPlayer instances must be accessed from a single application thread unless indicated * <li>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. * may use background threads to load data. These are implementation specific.
* </ul> * </ul>
*/ */
// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on
// developer.android.com.
public interface ExoPlayer extends Player { public interface ExoPlayer extends Player {
/** /**

View file

@ -2673,7 +2673,8 @@ import java.util.concurrent.TimeoutException;
"Player is accessed on the wrong thread.\n" "Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n" + "Current thread: '%s'\n"
+ "Expected 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()); Thread.currentThread().getName(), getApplicationLooper().getThread().getName());
if (throwsWhenUsingWrongThread) { if (throwsWhenUsingWrongThread) {
throw new IllegalStateException(message); throw new IllegalStateException(message);

View file

@ -1239,7 +1239,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
/* newPeriodId= */ periodId, /* newPeriodId= */ periodId,
/* oldTimeline= */ playbackInfo.timeline, /* oldTimeline= */ playbackInfo.timeline,
/* oldPeriodId= */ playbackInfo.periodId, /* oldPeriodId= */ playbackInfo.periodId,
/* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs); /* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs,
/* forceSetTargetOffsetOverride= */ true);
} }
} finally { } finally {
playbackInfo = playbackInfo =
@ -1882,7 +1883,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
/* oldPeriodId= */ playbackInfo.periodId, /* oldPeriodId= */ playbackInfo.periodId,
/* positionForTargetOffsetOverrideUs */ positionUpdate.setTargetLiveOffset /* positionForTargetOffsetOverrideUs */ positionUpdate.setTargetLiveOffset
? newPositionUs ? newPositionUs
: C.TIME_UNSET); : C.TIME_UNSET,
/* forceSetTargetOffsetOverride= */ false);
if (periodPositionChanged if (periodPositionChanged
|| newRequestedContentPositionUs != playbackInfo.requestedContentPositionUs) { || newRequestedContentPositionUs != playbackInfo.requestedContentPositionUs) {
Object oldPeriodUid = playbackInfo.periodId.periodUid; Object oldPeriodUid = playbackInfo.periodId.periodUid;
@ -1920,7 +1922,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
MediaPeriodId newPeriodId, MediaPeriodId newPeriodId,
Timeline oldTimeline, Timeline oldTimeline,
MediaPeriodId oldPeriodId, MediaPeriodId oldPeriodId,
long positionForTargetOffsetOverrideUs) long positionForTargetOffsetOverrideUs,
boolean forceSetTargetOffsetOverride)
throws ExoPlaybackException { throws ExoPlaybackException {
if (!shouldUseLivePlaybackSpeedControl(newTimeline, newPeriodId)) { if (!shouldUseLivePlaybackSpeedControl(newTimeline, newPeriodId)) {
// Live playback speed control is unused for the current period, reset speed to user-defined // 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; int oldWindowIndex = oldTimeline.getPeriodByUid(oldPeriodId.periodUid, period).windowIndex;
oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid; oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid;
} }
if (!Util.areEqual(oldWindowUid, windowUid)) { if (!Util.areEqual(oldWindowUid, windowUid) || forceSetTargetOffsetOverride) {
// Reset overridden target live offset to media values if window changes. // Reset overridden target live offset to media values if window changes or if seekTo
// default live position.
livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET); livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET);
} }
} }
@ -2074,7 +2078,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
/* newPeriodId= */ readingPeriodHolder.info.id, /* newPeriodId= */ readingPeriodHolder.info.id,
/* oldTimeline= */ playbackInfo.timeline, /* oldTimeline= */ playbackInfo.timeline,
/* oldPeriodId= */ oldReadingPeriodHolder.info.id, /* oldPeriodId= */ oldReadingPeriodHolder.info.id,
/* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET); /* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET,
/* forceSetTargetOffsetOverride= */ false);
if (readingPeriodHolder.prepared if (readingPeriodHolder.prepared
&& readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) { && readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) {

View file

@ -47,9 +47,12 @@ import java.lang.annotation.Target;
* valid state transitions are shown below, annotated with the methods that are called during each * valid state transitions are shown below, annotated with the methods that are called during each
* transition. * transition.
* *
* <p style="align:center"><img src="doc-files/renderer-states.svg" alt="Renderer state * <p style="align:center"><img
* transitions"> * src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/renderer-states.svg"
* alt="Renderer state transitions">
*/ */
// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on
// developer.android.com.
@UnstableApi @UnstableApi
public interface Renderer extends PlayerMessage.Target { public interface Renderer extends PlayerMessage.Target {

View file

@ -189,6 +189,8 @@ public interface AudioSink {
+ audioTrackState + audioTrackState
+ " " + " "
+ ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")") + ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")")
+ " "
+ format
+ (isRecoverable ? " (recoverable)" : ""), + (isRecoverable ? " (recoverable)" : ""),
audioTrackException); audioTrackException);
this.audioTrackState = audioTrackState; this.audioTrackState = audioTrackState;

View file

@ -80,7 +80,7 @@ public class DefaultAudioTrackBufferSizeProvider
/** /**
* Sets the minimum length for PCM {@link AudioTrack} buffers, in microseconds. Default is * 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 @CanIgnoreReturnValue
public Builder setMinPcmBufferDurationUs(int minPcmBufferDurationUs) { 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 * 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 @CanIgnoreReturnValue
public Builder setMaxPcmBufferDurationUs(int maxPcmBufferDurationUs) { 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 * 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 @CanIgnoreReturnValue
public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) { public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) {
@ -110,7 +110,7 @@ public class DefaultAudioTrackBufferSizeProvider
/** /**
* Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is * Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is
* {@value #PASSTHROUGH_BUFFER_DURATION_US}. * {@link #PASSTHROUGH_BUFFER_DURATION_US}.
*/ */
@CanIgnoreReturnValue @CanIgnoreReturnValue
public Builder setPassthroughBufferDurationUs(int passthroughBufferDurationUs) { 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}. * #OFFLOAD_BUFFER_DURATION_US}.
*/ */
@CanIgnoreReturnValue @CanIgnoreReturnValue
@ -130,7 +130,7 @@ public class DefaultAudioTrackBufferSizeProvider
/** /**
* Sets the multiplication factor to apply to the passthrough buffer for AC3 to avoid underruns * 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 @CanIgnoreReturnValue
public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) { public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) {

View file

@ -105,6 +105,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
private int codecMaxInputSize; private int codecMaxInputSize;
private boolean codecNeedsDiscardChannelsWorkaround; private boolean codecNeedsDiscardChannelsWorkaround;
@Nullable private Format inputFormat;
/** Codec used for DRM decryption only in passthrough and offload. */ /** Codec used for DRM decryption only in passthrough and offload. */
@Nullable private Format decryptOnlyCodecFormat; @Nullable private Format decryptOnlyCodecFormat;
@ -500,8 +501,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
@Nullable @Nullable
protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder) protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder)
throws ExoPlaybackException { throws ExoPlaybackException {
inputFormat = checkNotNull(formatHolder.format);
@Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder); @Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder);
eventDispatcher.inputFormatChanged(formatHolder.format, evaluation); eventDispatcher.inputFormatChanged(inputFormat, evaluation);
return evaluation; return evaluation;
} }
@ -604,6 +606,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
@Override @Override
protected void onDisabled() { protected void onDisabled() {
audioSinkNeedsReset = true; audioSinkNeedsReset = true;
inputFormat = null;
try { try {
audioSink.flush(); audioSink.flush();
} finally { } finally {
@ -711,7 +714,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount); fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount);
} catch (InitializationException e) { } catch (InitializationException e) {
throw createRendererException( 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) { } catch (WriteException e) {
throw createRendererException( throw createRendererException(
e, format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED); e, format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED);

View file

@ -136,9 +136,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
private final PlayerId playerId; private final PlayerId playerId;
/* package */ final MediaDrmCallback callback; private final MediaDrmCallback callback;
/* package */ final UUID uuid; private final UUID uuid;
/* package */ final ResponseHandler responseHandler; private final Looper playbackLooper;
private final ResponseHandler responseHandler;
private @DrmSession.State int state; private @DrmSession.State int state;
private int referenceCount; private int referenceCount;
@ -209,10 +210,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
this.playerId = playerId; this.playerId = playerId;
state = STATE_OPENING; state = STATE_OPENING;
this.playbackLooper = playbackLooper;
responseHandler = new ResponseHandler(playbackLooper); responseHandler = new ResponseHandler(playbackLooper);
} }
public boolean hasSessionId(byte[] sessionId) { public boolean hasSessionId(byte[] sessionId) {
verifyPlaybackThread();
return Arrays.equals(this.sessionId, sessionId); return Arrays.equals(this.sessionId, sessionId);
} }
@ -255,50 +258,59 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
@Override @Override
public final @DrmSession.State int getState() { public final @DrmSession.State int getState() {
verifyPlaybackThread();
return state; return state;
} }
@Override @Override
public boolean playClearSamplesWithoutKeys() { public boolean playClearSamplesWithoutKeys() {
verifyPlaybackThread();
return playClearSamplesWithoutKeys; return playClearSamplesWithoutKeys;
} }
@Override @Override
@Nullable @Nullable
public final DrmSessionException getError() { public final DrmSessionException getError() {
verifyPlaybackThread();
return state == STATE_ERROR ? lastException : null; return state == STATE_ERROR ? lastException : null;
} }
@Override @Override
public final UUID getSchemeUuid() { public final UUID getSchemeUuid() {
verifyPlaybackThread();
return uuid; return uuid;
} }
@Override @Override
@Nullable @Nullable
public final CryptoConfig getCryptoConfig() { public final CryptoConfig getCryptoConfig() {
verifyPlaybackThread();
return cryptoConfig; return cryptoConfig;
} }
@Override @Override
@Nullable @Nullable
public Map<String, String> queryKeyStatus() { public Map<String, String> queryKeyStatus() {
verifyPlaybackThread();
return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId); return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId);
} }
@Override @Override
@Nullable @Nullable
public byte[] getOfflineLicenseKeySetId() { public byte[] getOfflineLicenseKeySetId() {
verifyPlaybackThread();
return offlineLicenseKeySetId; return offlineLicenseKeySetId;
} }
@Override @Override
public boolean requiresSecureDecoder(String mimeType) { public boolean requiresSecureDecoder(String mimeType) {
verifyPlaybackThread();
return mediaDrm.requiresSecureDecoder(checkStateNotNull(sessionId), mimeType); return mediaDrm.requiresSecureDecoder(checkStateNotNull(sessionId), mimeType);
} }
@Override @Override
public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
verifyPlaybackThread();
if (referenceCount < 0) { if (referenceCount < 0) {
Log.e(TAG, "Session reference count less than zero: " + referenceCount); Log.e(TAG, "Session reference count less than zero: " + referenceCount);
referenceCount = 0; referenceCount = 0;
@ -326,6 +338,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
@Override @Override
public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) { public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
verifyPlaybackThread();
if (referenceCount <= 0) { if (referenceCount <= 0) {
Log.e(TAG, "release() called on a session that's already fully released."); Log.e(TAG, "release() called on a session that's already fully released.");
return; 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. // Internal classes.
@SuppressLint("HandlerLeak") @SuppressLint("HandlerLeak")

View file

@ -471,6 +471,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override @Override
public final void prepare() { public final void prepare() {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
if (prepareCallsCount++ != 0) { if (prepareCallsCount++ != 0) {
return; return;
} }
@ -487,6 +488,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override @Override
public final void release() { public final void release() {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
if (--prepareCallsCount != 0) { if (--prepareCallsCount != 0) {
return; return;
} }
@ -513,6 +515,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override @Override
public DrmSessionReference preacquireSession( public DrmSessionReference preacquireSession(
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) { @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
// Don't verify the playback thread, preacquireSession can be called from any thread.
checkState(prepareCallsCount > 0); checkState(prepareCallsCount > 0);
checkStateNotNull(playbackLooper); checkStateNotNull(playbackLooper);
PreacquiredSessionReference preacquiredSessionReference = PreacquiredSessionReference preacquiredSessionReference =
@ -525,6 +528,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Nullable @Nullable
public DrmSession acquireSession( public DrmSession acquireSession(
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) { @Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
checkState(prepareCallsCount > 0); checkState(prepareCallsCount > 0);
checkStateNotNull(playbackLooper); checkStateNotNull(playbackLooper);
return acquireSession( return acquireSession(
@ -599,6 +603,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override @Override
public @C.CryptoType int getCryptoType(Format format) { public @C.CryptoType int getCryptoType(Format format) {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
@C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType(); @C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType();
if (format.drmInitData == null) { if (format.drmInitData == null) {
int trackType = MimeTypes.getTrackType(format.sampleMimeType); 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}. * Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}.
* *

View file

@ -19,6 +19,7 @@ import android.media.MediaDrm;
import android.os.ConditionVariable; import android.os.ConditionVariable;
import android.os.Handler; import android.os.Handler;
import android.os.HandlerThread; import android.os.HandlerThread;
import android.os.Looper;
import android.util.Pair; import android.util.Pair;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; 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.DefaultDrmSessionManager.Mode;
import androidx.media3.exoplayer.drm.DrmSession.DrmSessionException; import androidx.media3.exoplayer.drm.DrmSession.DrmSessionException;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import com.google.common.util.concurrent.SettableFuture;
import java.util.Map; import java.util.Map;
import java.util.UUID; 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. */ /** Helper class to download, renew and release offline licenses. */
@RequiresApi(18) @RequiresApi(18)
@ -42,9 +46,10 @@ public final class OfflineLicenseHelper {
private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA = private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA =
new Format.Builder().setDrmInitData(new DrmInitData()).build(); new Format.Builder().setDrmInitData(new DrmInitData()).build();
private final ConditionVariable conditionVariable; private final ConditionVariable drmListenerConditionVariable;
private final DefaultDrmSessionManager drmSessionManager; private final DefaultDrmSessionManager drmSessionManager;
private final HandlerThread handlerThread; private final HandlerThread handlerThread;
private final Handler handler;
private final DrmSessionEventListener.EventDispatcher eventDispatcher; private final DrmSessionEventListener.EventDispatcher eventDispatcher;
/** /**
@ -156,28 +161,29 @@ public final class OfflineLicenseHelper {
this.eventDispatcher = eventDispatcher; this.eventDispatcher = eventDispatcher;
handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper"); handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper");
handlerThread.start(); handlerThread.start();
conditionVariable = new ConditionVariable(); handler = new Handler(handlerThread.getLooper());
drmListenerConditionVariable = new ConditionVariable();
DrmSessionEventListener eventListener = DrmSessionEventListener eventListener =
new DrmSessionEventListener() { new DrmSessionEventListener() {
@Override @Override
public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
conditionVariable.open(); drmListenerConditionVariable.open();
} }
@Override @Override
public void onDrmSessionManagerError( public void onDrmSessionManagerError(
int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) { int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) {
conditionVariable.open(); drmListenerConditionVariable.open();
} }
@Override @Override
public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
conditionVariable.open(); drmListenerConditionVariable.open();
} }
@Override @Override
public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
conditionVariable.open(); drmListenerConditionVariable.open();
} }
}; };
eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener); eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener);
@ -193,7 +199,8 @@ public final class OfflineLicenseHelper {
*/ */
public synchronized byte[] downloadLicense(Format format) throws DrmSessionException { public synchronized byte[] downloadLicense(Format format) throws DrmSessionException {
Assertions.checkArgument(format.drmInitData != null); 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) public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId)
throws DrmSessionException { throws DrmSessionException {
Assertions.checkNotNull(offlineLicenseKeySetId); Assertions.checkNotNull(offlineLicenseKeySetId);
return blockingKeyRequest( return acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
DefaultDrmSessionManager.MODE_DOWNLOAD, DefaultDrmSessionManager.MODE_DOWNLOAD,
offlineLicenseKeySetId, offlineLicenseKeySetId,
FORMAT_WITH_EMPTY_DRM_INIT_DATA); FORMAT_WITH_EMPTY_DRM_INIT_DATA);
@ -221,7 +228,7 @@ public final class OfflineLicenseHelper {
public synchronized void releaseLicense(byte[] offlineLicenseKeySetId) public synchronized void releaseLicense(byte[] offlineLicenseKeySetId)
throws DrmSessionException { throws DrmSessionException {
Assertions.checkNotNull(offlineLicenseKeySetId); Assertions.checkNotNull(offlineLicenseKeySetId);
blockingKeyRequest( acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
DefaultDrmSessionManager.MODE_RELEASE, DefaultDrmSessionManager.MODE_RELEASE,
offlineLicenseKeySetId, offlineLicenseKeySetId,
FORMAT_WITH_EMPTY_DRM_INIT_DATA); FORMAT_WITH_EMPTY_DRM_INIT_DATA);
@ -237,25 +244,39 @@ public final class OfflineLicenseHelper {
public synchronized Pair<Long, Long> getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId) public synchronized Pair<Long, Long> getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId)
throws DrmSessionException { throws DrmSessionException {
Assertions.checkNotNull(offlineLicenseKeySetId); Assertions.checkNotNull(offlineLicenseKeySetId);
drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET); DrmSession drmSession;
drmSessionManager.prepare(); try {
DrmSession drmSession = drmSession =
openBlockingKeyRequest( acquireFirstSessionOnHandlerThread(
DefaultDrmSessionManager.MODE_QUERY, DefaultDrmSessionManager.MODE_QUERY,
offlineLicenseKeySetId, offlineLicenseKeySetId,
FORMAT_WITH_EMPTY_DRM_INIT_DATA); FORMAT_WITH_EMPTY_DRM_INIT_DATA);
DrmSessionException error = drmSession.getError(); } catch (DrmSessionException e) {
Pair<Long, Long> licenseDurationRemainingSec = if (e.getCause() instanceof KeysExpiredException) {
WidevineUtil.getLicenseDurationRemainingSec(drmSession);
drmSession.release(eventDispatcher);
drmSessionManager.release();
if (error != null) {
if (error.getCause() instanceof KeysExpiredException) {
return Pair.create(0L, 0L); return Pair.create(0L, 0L);
} }
throw error; throw e;
}
SettableFuture<Pair<Long, Long>> 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. */ /** Releases the helper. Should be called when the helper is no longer required. */
@ -263,30 +284,146 @@ public final class OfflineLicenseHelper {
handlerThread.quit(); handlerThread.quit();
} }
private byte[] blockingKeyRequest( /**
* Returns the result of {@link DrmSession#getOfflineLicenseKeySetId()}, or throws {@link
* NullPointerException} if it's null.
*
* <p>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) @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format)
throws DrmSessionException { throws DrmSessionException {
drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET); DrmSession drmSession =
drmSessionManager.prepare(); acquireFirstSessionOnHandlerThread(licenseMode, offlineLicenseKeySetId, format);
DrmSession drmSession = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, format);
DrmSessionException error = drmSession.getError(); SettableFuture<byte @NullableType []> keySetId = SettableFuture.create();
byte[] keySetId = drmSession.getOfflineLicenseKeySetId(); handler.post(
() -> {
try {
keySetId.set(drmSession.getOfflineLicenseKeySetId());
} catch (Throwable e) {
keySetId.setException(e);
} finally {
drmSession.release(eventDispatcher); drmSession.release(eventDispatcher);
drmSessionManager.release();
if (error != null) {
throw error;
} }
return Assertions.checkNotNull(keySetId); });
try {
return Assertions.checkNotNull(keySetId.get());
} catch (ExecutionException | InterruptedException e) {
throw new IllegalStateException(e);
} finally {
releaseManagerOnHandlerThread();
}
} }
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}.
*
* <p>If key loading failed and {@link DrmSession#getState()} returns {@link
* DrmSession#STATE_ERROR} then this method releases the session and throws {@link
* DrmSession#getError()}.
*
* <p>Callers are responsible for the following:
*
* <ul>
* <li>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.
* <li>Releasing the returned {@link DrmSession} instance (on {@link #handlerThread}).
* <li>Releasing {@link #drmSessionManager} if a {@link DrmSession} instance is returned (the
* manager will be released before an exception is thrown).
* </ul>
*/
private DrmSession acquireFirstSessionOnHandlerThread(
@Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format)
throws DrmSessionException {
Assertions.checkNotNull(format.drmInitData); Assertions.checkNotNull(format.drmInitData);
SettableFuture<DrmSession> drmSessionFuture = SettableFuture.create();
drmListenerConditionVariable.close();
handler.post(
() -> {
try {
drmSessionManager.setPlayer(Assertions.checkNotNull(Looper.myLooper()), PlayerId.UNSET);
drmSessionManager.prepare();
try {
drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId);
conditionVariable.close(); drmSessionFuture.set(
DrmSession drmSession = drmSessionManager.acquireSession(eventDispatcher, format); Assertions.checkNotNull(
// Block current thread until key loading is finished drmSessionManager.acquireSession(eventDispatcher, format)));
conditionVariable.block(); } catch (Throwable e) {
return Assertions.checkNotNull(drmSession); 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<Void> 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);
}
} }
} }

View file

@ -642,14 +642,22 @@ public abstract class MediaCodecRenderer extends BaseRenderer {
@Override @Override
protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs)
throws ExoPlaybackException { throws ExoPlaybackException {
if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET) {
|| (pendingOutputStreamChanges.isEmpty() // This is the first stream.
&& lastProcessedOutputBufferTimeUs != C.TIME_UNSET
&& lastProcessedOutputBufferTimeUs >= largestQueuedPresentationTimeUs)) {
// This is the first stream, or the previous has been fully output already.
setOutputStreamInfo( setOutputStreamInfo(
new OutputStreamInfo( new OutputStreamInfo(
/* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs)); /* 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 { } else {
pendingOutputStreamChanges.add( pendingOutputStreamChanges.add(
new OutputStreamInfo(largestQueuedPresentationTimeUs, startPositionUs, offsetUs)); new OutputStreamInfo(largestQueuedPresentationTimeUs, startPositionUs, offsetUs));
@ -1581,7 +1589,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer {
@CallSuper @CallSuper
protected void onProcessedOutputBuffer(long presentationTimeUs) { protected void onProcessedOutputBuffer(long presentationTimeUs) {
lastProcessedOutputBufferTimeUs = presentationTimeUs; lastProcessedOutputBufferTimeUs = presentationTimeUs;
if (!pendingOutputStreamChanges.isEmpty() while (!pendingOutputStreamChanges.isEmpty()
&& presentationTimeUs >= pendingOutputStreamChanges.peek().previousStreamLastBufferTimeUs) { && presentationTimeUs >= pendingOutputStreamChanges.peek().previousStreamLastBufferTimeUs) {
setOutputStreamInfo(pendingOutputStreamChanges.poll()); setOutputStreamInfo(pendingOutputStreamChanges.poll());
onProcessedStreamChange(); onProcessedStreamChange();

View file

@ -71,17 +71,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* <li>{@code DashMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * <li>{@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 * ends in '.mpd' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
* explicitly set to {@link MimeTypes#APPLICATION_MPD} (Requires the <a * explicitly set to {@link MimeTypes#APPLICATION_MPD} (Requires the <a
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules">exoplayer-dash module * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-dash
* to be added</a> to the app). * module to be added</a> to the app).
* <li>{@code HlsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * <li>{@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 * ends in '.m3u8' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
* explicitly set to {@link MimeTypes#APPLICATION_M3U8} (Requires the <a * explicitly set to {@link MimeTypes#APPLICATION_M3U8} (Requires the <a
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules">exoplayer-hls module to * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-hls
* be added</a> to the app). * module to be added</a> to the app).
* <li>{@code SsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri} * <li>{@code SsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
* ends in '.ism', '.ism/Manifest' or if its {@link MediaItem.LocalConfiguration#mimeType * ends in '.ism', '.ism/Manifest' or if its {@link MediaItem.LocalConfiguration#mimeType
* mimeType field} is explicitly set to {@link MimeTypes#APPLICATION_SS} (Requires the <a * mimeType field} is explicitly set to {@link MimeTypes#APPLICATION_SS} (Requires the <a
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules"> * href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">
* exoplayer-smoothstreaming module to be added</a> to the app). * exoplayer-smoothstreaming module to be added</a> to the app).
* <li>{@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link * <li>{@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 * MediaItem.LocalConfiguration#uri uri} doesn't match one of the above. It tries to infer the

View file

@ -214,6 +214,115 @@ public class MediaCodecRendererTest {
inOrder.verify(renderer).onProcessedOutputBuffer(600); 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) { private FakeSampleStream createFakeSampleStream(Format format, long... sampleTimesUs) {
ImmutableList.Builder<FakeSampleStream.FakeSampleStreamItem> sampleListBuilder = ImmutableList.Builder<FakeSampleStream.FakeSampleStreamItem> sampleListBuilder =
ImmutableList.builder(); ImmutableList.builder();

View file

@ -236,9 +236,12 @@ public class DefaultDashChunkSource implements DashChunkSource {
// Segments are aligned across representations, so any segment index will do. // Segments are aligned across representations, so any segment index will do.
for (RepresentationHolder representationHolder : representationHolders) { for (RepresentationHolder representationHolder : representationHolders) {
if (representationHolder.segmentIndex != null) { if (representationHolder.segmentIndex != null) {
long segmentCount = representationHolder.getSegmentCount();
if (segmentCount == 0) {
continue;
}
long segmentNum = representationHolder.getSegmentNum(positionUs); long segmentNum = representationHolder.getSegmentNum(positionUs);
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum); long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
long segmentCount = representationHolder.getSegmentCount();
long secondSyncUs = long secondSyncUs =
firstSyncUs < positionUs firstSyncUs < positionUs
&& (segmentCount == DashSegmentIndex.INDEX_UNBOUNDED && (segmentCount == DashSegmentIndex.INDEX_UNBOUNDED
@ -594,7 +597,7 @@ public class DefaultDashChunkSource implements DashChunkSource {
} }
private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositionUs) { private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositionUs) {
if (!manifest.dynamic) { if (!manifest.dynamic || representationHolders[0].getSegmentCount() == 0) {
return C.TIME_UNSET; return C.TIME_UNSET;
} }
long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs); long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs);

View file

@ -26,7 +26,7 @@ locally. Instructions for doing this can be found in the [top level README][].
## Using the module ## Using the module
To use the module, follow the instructions on the 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 of the developer guide. The `AdsLoaderProvider` passed to the player's
`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA `DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA
module only supports players that are accessed on the application's main thread. module only supports players that are accessed on the application's main thread.

View file

@ -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 * 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 * 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. * #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms.
* *
* <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that * <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that

View file

@ -49,6 +49,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo; import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo;
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException; 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.RtspMessageChannel.InterleavedBinaryDataListener;
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader;
@ -577,8 +578,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
receivedAuthorizationRequest = true; receivedAuthorizationRequest = true;
return; return;
} }
// fall through: if unauthorized and no userInfo present, or previous authentication // if unauthorized and no userInfo present, or previous authentication
// unsuccessful. // 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: default:
dispatchRtspError( dispatchRtspError(
new RtspPlaybackException( new RtspPlaybackException(

View file

@ -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 // using TCP. Retrying will setup new loadables, so will not retry with the current
// loadables. // loadables.
retryWithRtpTcp(); retryWithRtpTcp();
isUsingRtpTcp = true;
} }
return; return;
} }
@ -644,8 +643,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Override @Override
public void onPlaybackError(RtspPlaybackException error) { public void onPlaybackError(RtspPlaybackException 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; playbackException = error;
} }
}
@Override @Override
public void onSessionTimelineUpdated( public void onSessionTimelineUpdated(
@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
private void retryWithRtpTcp() { private void retryWithRtpTcp() {
// Retry should only run once.
isUsingRtpTcp = true;
rtspClient.retryWithRtpTcp(); rtspClient.retryWithRtpTcp();
@Nullable @Nullable

View file

@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource {
} }
/** Thrown when an exception or error is encountered during loading an RTSP stream. */ /** 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) { public RtspPlaybackException(String message) {
super(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 MediaItem mediaItem;
private final RtpDataChannel.Factory rtpDataChannelFactory; private final RtpDataChannel.Factory rtpDataChannelFactory;
private final String userAgent; private final String userAgent;

View file

@ -453,4 +453,77 @@ public final class RtspClientTest {
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get); RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED); 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<RtspMediaTrack> 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);
}
} }

View file

@ -15,6 +15,7 @@
*/ */
package androidx.media3.exoplayer.rtsp; package androidx.media3.exoplayer.rtsp;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static java.lang.Math.min; 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.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import javax.net.SocketFactory; import javax.net.SocketFactory;
import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -58,30 +61,20 @@ import org.robolectric.annotation.Config;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public final class RtspPlaybackTest { public final class RtspPlaybackTest {
private static final long DEFAULT_TIMEOUT_MS = 8000;
private static final String SESSION_DESCRIPTION = private static final String SESSION_DESCRIPTION =
"v=0\r\n" "v=0\r\n"
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n" + "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
+ "s=Exoplayer test\r\n" + "s=Exoplayer test\r\n"
+ "t=0 0\r\n"; + "t=0 0\r\n";
private final Context applicationContext; private Context applicationContext;
private final CapturingRenderersFactory capturingRenderersFactory; private CapturingRenderersFactory capturingRenderersFactory;
private final Clock clock; private Clock clock;
private final FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel;
private final RtpDataChannel.Factory rtpDataChannelFactory;
private RtpPacketStreamDump aacRtpPacketStreamDump; private RtpPacketStreamDump aacRtpPacketStreamDump;
// ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment. // ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment.
private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump; private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump;
private RtspServer rtspServer;
/** Creates a new instance. */
public RtspPlaybackTest() {
applicationContext = ApplicationProvider.getApplicationContext();
capturingRenderersFactory = new CapturingRenderersFactory(applicationContext);
clock = new FakeClock(/* isAutoAdvancing= */ true);
fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
}
@Rule @Rule
public ShadowMediaCodecConfig mediaCodecConfig = public ShadowMediaCodecConfig mediaCodecConfig =
@ -89,20 +82,29 @@ public final class RtspPlaybackTest {
@Before @Before
public void setUp() throws Exception { 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"); aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json");
mpeg2tsRtpPacketStreamDump = mpeg2tsRtpPacketStreamDump =
RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json"); RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json");
} }
@After
public void tearDown() {
Util.closeQuietly(rtspServer);
}
@Test @Test
public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception { public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception {
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
ResponseProvider responseProvider = ResponseProvider responseProvider =
new ResponseProvider( new ResponseProvider(
clock, clock,
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
fakeRtpDataChannel); fakeRtpDataChannel);
rtspServer = new RtspServer(responseProvider);
try (RtspServer rtspServer = new RtspServer(responseProvider)) {
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
@ -113,18 +115,17 @@ public final class RtspPlaybackTest {
// Only setup the supported track (aac). // Only setup the supported track (aac).
assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump); assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump);
DumpFileAsserts.assertOutput( DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
}
} }
@Test @Test
public void prepare_noSupportedTrack_throwsPreparationError() throws Exception { public void prepare_noSupportedTrack_throwsPreparationError() throws Exception {
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
try (RtspServer rtspServer = RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
rtspServer =
new RtspServer( new RtspServer(
new ResponseProvider( new ResponseProvider(
clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel))) { clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel));
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
AtomicReference<Throwable> playbackError = new AtomicReference<>(); AtomicReference<Throwable> playbackError = new AtomicReference<>();
@ -139,11 +140,104 @@ public final class RtspPlaybackTest {
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
player.release(); player.release();
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<PlaybackException> 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()) assertThat(playbackError.get())
.hasCauseThat() .hasCauseThat()
.hasMessageThat() .hasMessageThat()
.contains("No playable track."); .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<PlaybackException> 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( private ExoPlayer createExoPlayer(
@ -163,16 +257,16 @@ public final class RtspPlaybackTest {
return player; 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; protected final Clock clock;
private final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks; protected final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks;
private final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps; protected final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps;
private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener; private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener;
private RtpPacketTransmitter packetTransmitter; protected RtpPacketTransmitter packetTransmitter;
/** /**
* Creates a new instance. * Creates a new instance.
@ -240,22 +334,54 @@ public final class RtspPlaybackTest {
} }
} }
private static final class FakeUdpDataSourceRtpDataChannel extends BaseDataSource private static final class ResponseProviderSupportingOnlyTcp extends ResponseProvider {
implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener {
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<RtpPacketStreamDump> 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<byte[]> packetQueue; private final ConcurrentLinkedQueue<byte[]> packetQueue;
public FakeUdpDataSourceRtpDataChannel() { public FakeBaseDataSourceRtpDataChannel() {
super(/* isNetwork= */ false); super(/* isNetwork= */ false);
packetQueue = new ConcurrentLinkedQueue<>(); packetQueue = new ConcurrentLinkedQueue<>();
} }
@Override @Override
public String getTransport() { public abstract String getTransport();
return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1);
}
@Override @Override
public int getLocalPort() { public int getLocalPort() {
@ -307,4 +433,49 @@ public final class RtspPlaybackTest {
return byteToRead; 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;
}
}
} }

View file

@ -16,6 +16,7 @@
package androidx.media3.extractor; package androidx.media3.extractor;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.ParserException; import androidx.media3.common.ParserException;
import androidx.media3.common.util.CodecSpecificDataUtil; import androidx.media3.common.util.CodecSpecificDataUtil;
@ -61,6 +62,9 @@ public final class HevcConfig {
int bufferPosition = 0; int bufferPosition = 0;
int width = Format.NO_VALUE; int width = Format.NO_VALUE;
int height = 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; float pixelWidthHeightRatio = 1;
@Nullable String codecs = null; @Nullable String codecs = null;
for (int i = 0; i < numberOfArrays; i++) { for (int i = 0; i < numberOfArrays; i++) {
@ -84,6 +88,9 @@ public final class HevcConfig {
buffer, bufferPosition, bufferPosition + nalUnitLength); buffer, bufferPosition, bufferPosition + nalUnitLength);
width = spsData.width; width = spsData.width;
height = spsData.height; height = spsData.height;
colorSpace = spsData.colorSpace;
colorRange = spsData.colorRange;
colorTransfer = spsData.colorTransfer;
pixelWidthHeightRatio = spsData.pixelWidthHeightRatio; pixelWidthHeightRatio = spsData.pixelWidthHeightRatio;
codecs = codecs =
CodecSpecificDataUtil.buildHevcCodecString( CodecSpecificDataUtil.buildHevcCodecString(
@ -102,7 +109,15 @@ public final class HevcConfig {
List<byte[]> initializationData = List<byte[]> initializationData =
csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer); csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer);
return new HevcConfig( return new HevcConfig(
initializationData, lengthSizeMinusOne + 1, width, height, pixelWidthHeightRatio, codecs); initializationData,
lengthSizeMinusOne + 1,
width,
height,
pixelWidthHeightRatio,
codecs,
colorSpace,
colorRange,
colorTransfer);
} catch (ArrayIndexOutOfBoundsException e) { } catch (ArrayIndexOutOfBoundsException e) {
throw ParserException.createForMalformedContainer("Error parsing HEVC config", e); throw ParserException.createForMalformedContainer("Error parsing HEVC config", e);
} }
@ -129,6 +144,22 @@ public final class HevcConfig {
/** The pixel width to height ratio. */ /** The pixel width to height ratio. */
public final float pixelWidthHeightRatio; 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. * 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 width,
int height, int height,
float pixelWidthHeightRatio, float pixelWidthHeightRatio,
@Nullable String codecs) { @Nullable String codecs,
@C.ColorSpace int colorSpace,
@C.ColorRange int colorRange,
@C.ColorTransfer int colorTransfer) {
this.initializationData = initializationData; this.initializationData = initializationData;
this.nalUnitLengthFieldLength = nalUnitLengthFieldLength; this.nalUnitLengthFieldLength = nalUnitLengthFieldLength;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.pixelWidthHeightRatio = pixelWidthHeightRatio; this.pixelWidthHeightRatio = pixelWidthHeightRatio;
this.codecs = codecs; this.codecs = codecs;
this.colorSpace = colorSpace;
this.colorRange = colorRange;
this.colorTransfer = colorTransfer;
} }
} }

View file

@ -19,6 +19,8 @@ import static java.lang.Math.min;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.ColorInfo;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
@ -110,6 +112,9 @@ public final class NalUnitUtil {
public final int width; public final int width;
public final int height; public final int height;
public final float pixelWidthHeightRatio; public final float pixelWidthHeightRatio;
public final @C.ColorSpace int colorSpace;
public final @C.ColorRange int colorRange;
public final @C.ColorTransfer int colorTransfer;
public H265SpsData( public H265SpsData(
int generalProfileSpace, int generalProfileSpace,
@ -121,7 +126,10 @@ public final class NalUnitUtil {
int seqParameterSetId, int seqParameterSetId,
int width, int width,
int height, int height,
float pixelWidthHeightRatio) { float pixelWidthHeightRatio,
@C.ColorSpace int colorSpace,
@C.ColorRange int colorRange,
@C.ColorTransfer int colorTransfer) {
this.generalProfileSpace = generalProfileSpace; this.generalProfileSpace = generalProfileSpace;
this.generalTierFlag = generalTierFlag; this.generalTierFlag = generalTierFlag;
this.generalProfileIdc = generalProfileIdc; this.generalProfileIdc = generalProfileIdc;
@ -132,6 +140,9 @@ public final class NalUnitUtil {
this.width = width; this.width = width;
this.height = height; this.height = height;
this.pixelWidthHeightRatio = pixelWidthHeightRatio; this.pixelWidthHeightRatio = pixelWidthHeightRatio;
this.colorSpace = colorSpace;
this.colorRange = colorRange;
this.colorTransfer = colorTransfer;
} }
} }
@ -488,6 +499,10 @@ public final class NalUnitUtil {
public static H265SpsData parseH265SpsNalUnitPayload( public static H265SpsData parseH265SpsNalUnitPayload(
byte[] nalData, int nalOffset, int nalLimit) { byte[] nalData, int nalOffset, int nalLimit) {
ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, 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 data.skipBits(4); // sps_video_parameter_set_id
int maxSubLayersMinus1 = data.readBits(3); int maxSubLayersMinus1 = data.readBits(3);
data.skipBit(); // sps_temporal_id_nesting_flag data.skipBit(); // sps_temporal_id_nesting_flag
@ -594,10 +609,17 @@ public final class NalUnitUtil {
data.skipBit(); // overscan_appropriate_flag data.skipBit(); // overscan_appropriate_flag
} }
if (data.readBit()) { // video_signal_type_present_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 if (data.readBit()) { // colour_description_present_flag
// colour_primaries, transfer_characteristics, matrix_coeffs int colorPrimaries = data.readBits(8); // colour_primaries
data.skipBits(24); 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 if (data.readBit()) { // chroma_loc_info_present_flag
@ -622,7 +644,10 @@ public final class NalUnitUtil {
seqParameterSetId, seqParameterSetId,
frameWidth, frameWidth,
frameHeight, frameHeight,
pixelWidthHeightRatio); pixelWidthHeightRatio,
colorSpace,
colorRange,
colorTransfer);
} }
/** /**

View file

@ -176,6 +176,9 @@ import java.util.List;
@SuppressWarnings("ConstantCaseForConstants") @SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_ddts = 0x64647473; public static final int TYPE_ddts = 0x64647473;
@SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_udts = 0x75647473;
@SuppressWarnings("ConstantCaseForConstants") @SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_tfdt = 0x74666474; public static final int TYPE_tfdt = 0x74666474;

View file

@ -1164,6 +1164,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio; pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio;
} }
codecs = hevcConfig.codecs; codecs = hevcConfig.codecs;
colorSpace = hevcConfig.colorSpace;
colorRange = hevcConfig.colorRange;
colorTransfer = hevcConfig.colorTransfer;
} else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) { } else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) {
@Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent); @Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent);
if (dolbyVisionConfig != null) { if (dolbyVisionConfig != null) {
@ -1173,6 +1176,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} else if (childAtomType == Atom.TYPE_vpcC) { } else if (childAtomType == Atom.TYPE_vpcC) {
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null); ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9; 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) { } else if (childAtomType == Atom.TYPE_av1C) {
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null); ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
mimeType = MimeTypes.VIDEO_AV1; mimeType = MimeTypes.VIDEO_AV1;
@ -1252,6 +1265,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
} }
} else if (childAtomType == Atom.TYPE_colr) { } else if (childAtomType == Atom.TYPE_colr) {
// 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(); int colorType = parent.readInt();
if (colorType == TYPE_nclx || colorType == TYPE_nclc) { 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 // For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
@ -1274,6 +1293,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType)); Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
} }
} }
}
childPosition += childAtomSize; childPosition += childAtomSize;
} }
@ -1560,7 +1580,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
// because these streams can carry simultaneously multiple representations of the same // because these streams can carry simultaneously multiple representations of the same
// audio. Use stereo by default. // audio. Use stereo by default.
channelCount = 2; channelCount = 2;
} else if (childAtomType == Atom.TYPE_ddts) { } else if (childAtomType == Atom.TYPE_ddts || childAtomType == Atom.TYPE_udts) {
out.format = out.format =
new Format.Builder() new Format.Builder()
.setId(trackId) .setId(trackId)

View file

@ -194,6 +194,9 @@ public final class NalUnitUtilTest {
assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1); assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1);
assertThat(spsData.seqParameterSetId).isEqualTo(0); assertThat(spsData.seqParameterSetId).isEqualTo(0);
assertThat(spsData.width).isEqualTo(3840); assertThat(spsData.width).isEqualTo(3840);
assertThat(spsData.colorSpace).isEqualTo(6);
assertThat(spsData.colorRange).isEqualTo(2);
assertThat(spsData.colorTransfer).isEqualTo(6);
} }
private static byte[] buildTestData() { private static byte[] buildTestData() {

View file

@ -43,6 +43,8 @@ dependencies {
androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
testImplementation project(modulePrefix + 'test-utils') testImplementation project(modulePrefix + 'test-utils')
testImplementation project(modulePrefix + 'test-utils-robolectric')
testImplementation project(modulePrefix + 'lib-exoplayer')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion
} }

View file

@ -89,8 +89,8 @@ import androidx.media3.common.util.Util;
int controllerInterfaceVersion = int controllerInterfaceVersion =
bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0); bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0);
String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME)); String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME));
int pid = bundle.getInt(FIELD_PID, /* defaultValue= */ 0); checkArgument(bundle.containsKey(FIELD_PID));
checkArgument(pid != 0); int pid = bundle.getInt(FIELD_PID);
@Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS); @Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS);
return new ConnectionRequest( return new ConnectionRequest(
libraryVersion, libraryVersion,

View file

@ -33,8 +33,6 @@ import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.DoNotInline; import androidx.annotation.DoNotInline;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -245,7 +243,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
private final String channelId; private final String channelId;
@StringRes private final int channelNameResourceId; @StringRes private final int channelNameResourceId;
private final NotificationManager notificationManager; private final NotificationManager notificationManager;
private final Handler mainHandler;
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback; private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@DrawableRes private int smallIconResourceId; @DrawableRes private int smallIconResourceId;
@ -278,7 +275,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
notificationManager = notificationManager =
checkStateNotNull( checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
mainHandler = new Handler(Looper.getMainLooper());
smallIconResourceId = R.drawable.media3_notification_small_icon; smallIconResourceId = R.drawable.media3_notification_small_icon;
} }
@ -346,7 +342,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
pendingOnBitmapLoadedFutureCallback, pendingOnBitmapLoadedFutureCallback,
// This callback must be executed on the next looper iteration, after this method has // This callback must be executed on the next looper iteration, after this method has
// returned a media notification. // returned a media notification.
mainHandler::post); mediaSession.getImpl().getApplicationHandler()::post);
} }
} }
} }

View file

@ -1795,7 +1795,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
currentTimeline = currentTimeline =
isQueueChanged isQueueChanged
? QueueTimeline.create(newLegacyPlayerInfo.queue) ? QueueTimeline.create(newLegacyPlayerInfo.queue)
: new QueueTimeline((QueueTimeline) oldControllerInfo.playerInfo.timeline); : ((QueueTimeline) oldControllerInfo.playerInfo.timeline).copy();
boolean isMetadataCompatChanged = boolean isMetadataCompatChanged =
oldLegacyPlayerInfo.mediaMetadataCompat != newLegacyPlayerInfo.mediaMetadataCompat oldLegacyPlayerInfo.mediaMetadataCompat != newLegacyPlayerInfo.mediaMetadataCompat
@ -1987,8 +1987,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
Integer mediaItemTransitionReason; Integer mediaItemTransitionReason;
boolean isOldTimelineEmpty = oldControllerInfo.playerInfo.timeline.isEmpty(); boolean isOldTimelineEmpty = oldControllerInfo.playerInfo.timeline.isEmpty();
boolean isNewTimelineEmpty = newControllerInfo.playerInfo.timeline.isEmpty(); boolean isNewTimelineEmpty = newControllerInfo.playerInfo.timeline.isEmpty();
int newCurrentMediaItemIndex =
newControllerInfo.playerInfo.sessionPositionInfo.positionInfo.mediaItemIndex;
if (isOldTimelineEmpty && isNewTimelineEmpty) { if (isOldTimelineEmpty && isNewTimelineEmpty) {
// Still empty Timelines. // Still empty Timelines.
discontinuityReason = null; discontinuityReason = null;
@ -2000,13 +1998,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} else { } else {
MediaItem oldCurrentMediaItem = MediaItem oldCurrentMediaItem =
checkStateNotNull(oldControllerInfo.playerInfo.getCurrentMediaItem()); checkStateNotNull(oldControllerInfo.playerInfo.getCurrentMediaItem());
int oldCurrentMediaItemIndexInNewTimeline = boolean oldCurrentMediaItemExistsInNewTimeline =
((QueueTimeline) newControllerInfo.playerInfo.timeline).indexOf(oldCurrentMediaItem); ((QueueTimeline) newControllerInfo.playerInfo.timeline).contains(oldCurrentMediaItem);
if (oldCurrentMediaItemIndexInNewTimeline == C.INDEX_UNSET) { if (!oldCurrentMediaItemExistsInNewTimeline) {
// Old current item is removed. // Old current item is removed.
discontinuityReason = Player.DISCONTINUITY_REASON_REMOVE; discontinuityReason = Player.DISCONTINUITY_REASON_REMOVE;
mediaItemTransitionReason = Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; mediaItemTransitionReason = Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
} else if (oldCurrentMediaItemIndexInNewTimeline == newCurrentMediaItemIndex) { } else if (oldCurrentMediaItem.equals(newControllerInfo.playerInfo.getCurrentMediaItem())) {
// Current item is the same. // Current item is the same.
long oldCurrentPosition = long oldCurrentPosition =
MediaUtils.convertToCurrentPositionMs( MediaUtils.convertToCurrentPositionMs(

View file

@ -35,6 +35,10 @@ public final class MediaNotification {
/** /**
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending * Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
* intents} for notifications. * intents} for notifications.
*
* <p>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 @UnstableApi
public interface ActionFactory { public interface ActionFactory {
@ -109,10 +113,20 @@ public final class MediaNotification {
* *
* <p>The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat * <p>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}. * notification channel}, which is required to show notification for {@code SDK_INT >= 26}.
*
* <p>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 @UnstableApi
public interface Provider { public interface Provider {
/** Receives updates for a notification. */ /**
* Receives updates for a notification.
*
* <p>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 { interface Callback {
/** /**
* Called when a {@link MediaNotification} is changed. * Called when a {@link MediaNotification} is changed.

View file

@ -50,6 +50,8 @@ import java.util.concurrent.TimeoutException;
/** /**
* Manages media notifications for a {@link MediaSessionService} and sets the service as * Manages media notifications for a {@link MediaSessionService} and sets the service as
* foreground/background according to the player state. * foreground/background according to the player state.
*
* <p>All methods must be called on the main thread.
*/ */
/* package */ final class MediaNotificationManager { /* package */ final class MediaNotificationManager {
@ -96,11 +98,12 @@ import java.util.concurrent.TimeoutException;
.setListener(listener) .setListener(listener)
.setApplicationLooper(Looper.getMainLooper()) .setApplicationLooper(Looper.getMainLooper())
.buildAsync(); .buildAsync();
controllerMap.put(session, controllerFuture);
controllerFuture.addListener( controllerFuture.addListener(
() -> { () -> {
try { try {
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
listener.onConnected(); listener.onConnected(shouldShowNotification(session));
controller.addListener(listener); controller.addListener(listener);
} catch (CancellationException } catch (CancellationException
| ExecutionException | ExecutionException
@ -111,7 +114,6 @@ import java.util.concurrent.TimeoutException;
} }
}, },
mainExecutor); mainExecutor);
controllerMap.put(session, controllerFuture);
} }
public void removeSession(MediaSession session) { public void removeSession(MediaSession session) {
@ -123,46 +125,19 @@ import java.util.concurrent.TimeoutException;
} }
public void onCustomAction(MediaSession session, String action, Bundle extras) { public void onCustomAction(MediaSession session, String action, Bundle extras) {
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session); @Nullable MediaController mediaController = getConnectedControllerForSession(session);
if (controllerFuture == null) { if (mediaController == null) {
return; return;
} }
try { // Let the notification provider handle the command first before forwarding it directly.
MediaController mediaController = checkStateNotNull(Futures.getDone(controllerFuture)); Util.postOrRun(
new Handler(session.getPlayer().getApplicationLooper()),
() -> {
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
@Nullable SessionCommand customCommand = null; mainExecutor.execute(
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { () -> sendCustomCommandIfCommandIsAvailable(mediaController, action));
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
&& command.customAction.equals(action)) {
customCommand = command;
break;
}
}
if (customCommand != null
&& mediaController.getAvailableSessionCommands().contains(customCommand)) {
ListenableFuture<SessionResult> future =
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY);
Futures.addCallback(
future,
new FutureCallback<SessionResult>() {
@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; int notificationSequence = ++totalNotificationCount;
ImmutableList<CommandButton> customLayout = checkStateNotNull(customLayoutMap.get(session));
MediaNotification.Provider.Callback callback = MediaNotification.Provider.Callback callback =
notification -> notification ->
mainExecutor.execute( mainExecutor.execute(
() -> onNotificationUpdated(notificationSequence, session, notification)); () -> onNotificationUpdated(notificationSequence, session, notification));
Util.postOrRun(
new Handler(session.getPlayer().getApplicationLooper()),
() -> {
MediaNotification mediaNotification = MediaNotification mediaNotification =
this.mediaNotificationProvider.createNotification( this.mediaNotificationProvider.createNotification(
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback); session, customLayout, actionFactory, callback);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); mainExecutor.execute(
() ->
updateNotificationInternal(
session, mediaNotification, startInForegroundRequired));
});
} }
public boolean isStartedInForeground() { public boolean isStartedInForeground() {
return startedInForeground; 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( private void onNotificationUpdated(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) { int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) { if (notificationSequence == totalNotificationCount) {
boolean startInForegroundRequired = boolean startInForegroundRequired =
MediaSessionService.shouldRunInForeground( shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
session, /* startInForegroundWhenPaused= */ false);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
} }
} }
@ -236,8 +226,7 @@ import java.util.concurrent.TimeoutException;
private void maybeStopForegroundService(boolean removeNotifications) { private void maybeStopForegroundService(boolean removeNotifications) {
List<MediaSession> sessions = mediaSessionService.getSessions(); List<MediaSession> sessions = mediaSessionService.getSessions();
for (int i = 0; i < sessions.size(); i++) { for (int i = 0; i < sessions.size(); i++) {
if (MediaSessionService.shouldRunInForeground( if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
return; return;
} }
} }
@ -251,9 +240,56 @@ import java.util.concurrent.TimeoutException;
} }
} }
private static boolean shouldShowNotification(MediaSession session) { private boolean shouldShowNotification(MediaSession session) {
Player player = session.getPlayer(); MediaController controller = getConnectedControllerForSession(session);
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE; return controller != null
&& !controller.getCurrentTimeline().isEmpty()
&& controller.getPlaybackState() != Player.STATE_IDLE;
}
@Nullable
private MediaController getConnectedControllerForSession(MediaSession session) {
@Nullable ListenableFuture<MediaController> 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<SessionResult> future =
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY);
Futures.addCallback(
future,
new FutureCallback<SessionResult>() {
@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 private static final class MediaControllerListener
@ -271,8 +307,8 @@ import java.util.concurrent.TimeoutException;
this.customLayoutMap = customLayoutMap; this.customLayoutMap = customLayoutMap;
} }
public void onConnected() { public void onConnected(boolean shouldShowNotification) {
if (shouldShowNotification(session)) { if (shouldShowNotification) {
mediaSessionService.onUpdateNotificationInternal( mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false); session, /* startInForegroundWhenPaused= */ false);
} }

View file

@ -701,6 +701,9 @@ public class MediaSession {
* </tr> * </tr>
* </table> * </table>
* *
* <p>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 controller The controller to specify layout.
* @param layout The ordered list of {@link CommandButton}. * @param layout The ordered list of {@link CommandButton}.
*/ */
@ -793,6 +796,9 @@ public class MediaSession {
* *
* <p>This is a synchronous call and doesn't wait for results from the controller. * <p>This is a synchronous call and doesn't wait for results from the controller.
* *
* <p>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 controller The controller to send the extras to.
* @param sessionExtras The session extras. * @param sessionExtras The session extras.
*/ */
@ -816,6 +822,9 @@ public class MediaSession {
* *
* <p>A command is not accepted if it is not a custom command. * <p>A command is not accepted if it is not a custom command.
* *
* <p>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 controller The controller to send the custom command to.
* @param command A custom command. * @param command A custom command.
* @param args A {@link Bundle} for additional arguments. May be empty. * @param args A {@link Bundle} for additional arguments. May be empty.
@ -890,12 +899,20 @@ public class MediaSession {
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs); impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs);
} }
/** Sets the {@linkplain Listener listener}. */ /**
* Sets the {@linkplain Listener listener}.
*
* <p>This method must be called on the main thread.
*/
/* package */ void setListener(Listener listener) { /* package */ void setListener(Listener listener) {
impl.setMediaSessionListener(listener); impl.setMediaSessionListener(listener);
} }
/** Clears the {@linkplain Listener listener}. */ /**
* Clears the {@linkplain Listener listener}.
*
* <p>This method must be called on the main thread.
*/
/* package */ void clearListener() { /* package */ void clearListener() {
impl.clearMediaSessionListener(); impl.clearMediaSessionListener();
} }
@ -1426,7 +1443,11 @@ public class MediaSession {
default void onRenderedFirstFrame(int seq) throws RemoteException {} default void onRenderedFirstFrame(int seq) throws RemoteException {}
} }
/** Listener for media session events */ /**
* Listener for media session events.
*
* <p>All methods must be called on the main thread.
*/
/* package */ interface Listener { /* package */ interface Listener {
/** /**

View file

@ -15,21 +15,17 @@
*/ */
package androidx.media3.session; 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.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull; 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.common.util.Util.postOrRun;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED; 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_ERROR_UNKNOWN;
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo; import android.content.pm.ResolveInfo;
import android.net.Uri; import android.net.Uri;
@ -43,7 +39,6 @@ import android.os.Process;
import android.os.RemoteException; import android.os.RemoteException;
import android.os.SystemClock; import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent;
import androidx.annotation.FloatRange; import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy; import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -66,7 +61,6 @@ import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize; import androidx.media3.common.VideoSize;
import androidx.media3.common.text.CueGroup; import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerCb; import androidx.media3.session.MediaSession.ControllerCb;
import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition; 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.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.initialization.qual.Initialized; import org.checkerframework.checker.initialization.qual.Initialized;
/* package */ class MediaSessionImpl { /* package */ class MediaSessionImpl {
@ -115,13 +111,13 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private final SessionToken sessionToken; private final SessionToken sessionToken;
private final MediaSession instance; private final MediaSession instance;
@Nullable private final PendingIntent sessionActivity; @Nullable private final PendingIntent sessionActivity;
private final PendingIntent mediaButtonIntent;
@Nullable private final BroadcastReceiver broadcastReceiver;
private final Handler applicationHandler; private final Handler applicationHandler;
private final BitmapLoader bitmapLoader; private final BitmapLoader bitmapLoader;
private final Runnable periodicSessionPositionInfoUpdateRunnable; private final Runnable periodicSessionPositionInfoUpdateRunnable;
private final Handler mainHandler;
@Nullable private PlayerListener playerListener; @Nullable private PlayerListener playerListener;
@Nullable private MediaSession.Listener mediaSessionListener; @Nullable private MediaSession.Listener mediaSessionListener;
private PlayerInfo playerInfo; private PlayerInfo playerInfo;
@ -156,6 +152,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
sessionStub = new MediaSessionStub(thisRef); sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity; this.sessionActivity = sessionActivity;
mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper()); applicationHandler = new Handler(player.getApplicationLooper());
this.callback = callback; this.callback = callback;
this.bitmapLoader = bitmapLoader; this.bitmapLoader = bitmapLoader;
@ -189,52 +186,21 @@ import org.checkerframework.checker.initialization.qual.Initialized;
sessionStub, sessionStub,
tokenExtras); tokenExtras);
@Nullable ComponentName mbrComponent;
synchronized (STATIC_LOCK) { synchronized (STATIC_LOCK) {
if (!componentNamesInitialized) { if (!componentNamesInitialized) {
serviceComponentName = MediaSessionImpl.serviceComponentName =
getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE); getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE);
if (serviceComponentName == null) { if (MediaSessionImpl.serviceComponentName == null) {
serviceComponentName = MediaSessionImpl.serviceComponentName =
getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE); getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE);
} }
componentNamesInitialized = true; 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 = sessionLegacyStub =
new MediaSessionLegacyStub(thisRef, mbrComponent, mediaButtonIntent, applicationHandler); new MediaSessionLegacyStub(
/* session= */ thisRef, sessionUri, serviceComponentName, applicationHandler);
PlayerWrapper playerWrapper = new PlayerWrapper(player); PlayerWrapper playerWrapper = new PlayerWrapper(player);
this.playerWrapper = playerWrapper; this.playerWrapper = playerWrapper;
@ -278,8 +244,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
playerInfo = newPlayerWrapper.createPlayerInfoForBundling(); playerInfo = newPlayerWrapper.createPlayerInfoForBundling();
onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage( handleAvailablePlayerCommandsChanged(newPlayerWrapper.getAvailableCommands());
/* excludeTimeline= */ false, /* excludeTracks= */ false);
} }
public void release() { public void release() {
@ -305,10 +270,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
Log.w(TAG, "Exception thrown while closing", e); Log.w(TAG, "Exception thrown while closing", e);
} }
sessionLegacyStub.release(); sessionLegacyStub.release();
mediaButtonIntent.cancel();
if (broadcastReceiver != null) {
context.unregisterReceiver(broadcastReceiver);
}
sessionStub.release(); sessionStub.release();
} }
@ -395,7 +356,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private void dispatchOnPlayerInfoChanged( private void dispatchOnPlayerInfoChanged(
PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) { PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) {
playerInfo = sessionStub.generateAndCacheUniqueTrackGroupIds(playerInfo);
List<ControllerInfo> controllers = List<ControllerInfo> controllers =
sessionStub.getConnectedControllersManager().getConnectedControllers(); sessionStub.getConnectedControllersManager().getConnectedControllers();
for (int i = 0; i < controllers.size(); i++) { for (int i = 0; i < controllers.size(); i++) {
@ -589,12 +550,25 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
/* package */ void onNotificationRefreshRequired() { /* package */ void onNotificationRefreshRequired() {
postOrRun(
mainHandler,
() -> {
if (this.mediaSessionListener != null) { if (this.mediaSessionListener != null) {
this.mediaSessionListener.onNotificationRefreshRequired(instance); this.mediaSessionListener.onNotificationRefreshRequired(instance);
} }
});
} }
/* package */ boolean onPlayRequested() { /* package */ boolean onPlayRequested() {
if (Looper.myLooper() != Looper.getMainLooper()) {
SettableFuture<Boolean> playRequested = SettableFuture.create();
mainHandler.post(() -> playRequested.set(onPlayRequested()));
try {
return playRequested.get();
} catch (InterruptedException | ExecutionException e) {
throw new IllegalStateException(e);
}
}
if (this.mediaSessionListener != null) { if (this.mediaSessionListener != null) {
return this.mediaSessionListener.onPlayRequested(instance); 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 */ /* @FunctionalInterface */
interface RemoteControllerTask { interface RemoteControllerTask {
@ -1182,16 +1170,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
if (player == null) { if (player == null) {
return; return;
} }
boolean excludeTracks = !availableCommands.contains(COMMAND_GET_TRACKS); session.handleAvailablePlayerCommandsChanged(availableCommands);
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));
} }
@Override @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 class PlayerInfoChangedHandler extends Handler {
private static final int MSG_PLAYER_INFO_CHANGED = 1; private static final int MSG_PLAYER_INFO_CHANGED = 1;

View file

@ -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.Player.STATE_IDLE;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull; 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.common.util.Util.postOrRun;
import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES; import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM; 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 static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
@ -107,6 +112,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private static final String TAG = "MediaSessionLegacyStub"; 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_PREFIX = "androidx.media3.session.id";
private static final String DEFAULT_MEDIA_SESSION_TAG_DELIM = "."; 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 MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSessionCompat sessionCompat; private final MediaSessionCompat sessionCompat;
private final String appPackageName; private final String appPackageName;
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
private final boolean canResumePlaybackOnStart;
@Nullable private VolumeProviderCompat volumeProviderCompat; @Nullable private VolumeProviderCompat volumeProviderCompat;
private volatile long connectionTimeoutMs; private volatile long connectionTimeoutMs;
@ -130,8 +139,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
public MediaSessionLegacyStub( public MediaSessionLegacyStub(
MediaSessionImpl session, MediaSessionImpl session,
ComponentName mbrComponent, Uri sessionUri,
PendingIntent mediaButtonIntent, @Nullable ComponentName serviceComponentName,
Handler handler) { Handler handler) {
sessionImpl = session; sessionImpl = session;
Context context = sessionImpl.getContext(); Context context = sessionImpl.getContext();
@ -145,6 +154,44 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
connectedControllersManager = new ConnectedControllersManager<>(session); connectedControllersManager = new ConnectedControllersManager<>(session);
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS; 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 = String sessionCompatId =
TextUtils.join( TextUtils.join(
DEFAULT_MEDIA_SESSION_TAG_DELIM, DEFAULT_MEDIA_SESSION_TAG_DELIM,
@ -153,7 +200,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
new MediaSessionCompat( new MediaSessionCompat(
context, context,
sessionCompatId, sessionCompatId,
mbrComponent, receiverComponentName,
mediaButtonIntent, mediaButtonIntent,
session.getToken().getExtras()); session.getToken().getExtras());
@ -168,12 +215,38 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionCompat.setCallback(thisRef, handler); 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<ResolveInfo> 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. */ /** Starts to receive commands. */
public void start() { public void start() {
sessionCompat.setActive(true); sessionCompat.setActive(true);
} }
public void release() { public void release() {
if (!canResumePlaybackOnStart) {
setMediaButtonReceiver(sessionCompat, /* mediaButtonReceiverIntent= */ null);
}
if (runtimeBroadcastReceiver != null) {
sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver);
}
sessionCompat.release(); sessionCompat.release();
} }
@ -832,6 +905,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionCompat.setMetadata(metadataCompat); 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. @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) { private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) {
sessionCompat.setQueue(queue); sessionCompat.setQueue(queue);
@ -987,6 +1066,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionImpl.getSessionCompat().setExtras(sessionExtras); sessionImpl.getSessionCompat().setExtras(sessionExtras);
} }
@Override
public void sendCustomCommand(int seq, SessionCommand command, Bundle args) {
sessionImpl.getSessionCompat().sendSessionEvent(command.customAction, args);
}
@Override @Override
public void onPlayWhenReadyChanged( public void onPlayWhenReadyChanged(
int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason) int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason)
@ -1237,11 +1321,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
lastMediaMetadata = newMediaMetadata; lastMediaMetadata = newMediaMetadata;
lastDurationMs = newDurationMs; lastDurationMs = newDurationMs;
if (currentMediaItem == null) {
setMetadata(sessionCompat, /* metadataCompat= */ null);
return;
}
@Nullable Bitmap artworkBitmap = null; @Nullable Bitmap artworkBitmap = null;
ListenableFuture<Bitmap> bitmapFuture = ListenableFuture<Bitmap> bitmapFuture =
sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata); sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata);
@ -1353,4 +1432,24 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private static String getBitmapLoadErrorMessage(Throwable throwable) { private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage(); 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);
}
}
} }

View file

@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap; import androidx.collection.ArrayMap;
import androidx.media.MediaBrowserServiceCompat; import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager; import androidx.media.MediaSessionManager;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
@ -183,7 +182,6 @@ public abstract class MediaSessionService extends Service {
@Nullable @Nullable
private Listener listener; private Listener listener;
@GuardedBy("lock")
private boolean defaultMethodCalled; private boolean defaultMethodCalled;
/** Creates a service. */ /** Creates a service. */
@ -198,6 +196,8 @@ public abstract class MediaSessionService extends Service {
* Called when the service is created. * Called when the service is created.
* *
* <p>Override this method if you need your own initialization. * <p>Override this method if you need your own initialization.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -234,7 +234,7 @@ public abstract class MediaSessionService extends Service {
* <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link * <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link
* ControllerInfo#getConnectionHints()} have no meaning. * ControllerInfo#getConnectionHints()} have no meaning.
* *
* <p>This method is always called on the main thread. * <p>This method will be called on the main thread.
* *
* @param controllerInfo The information of the controller that is trying to connect. * @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. * @return A {@link MediaSession} for the controller, or {@code null} to reject the connection.
@ -251,6 +251,8 @@ public abstract class MediaSessionService extends Service {
* <p>The added session will be removed automatically {@linkplain MediaSession#release() when the * <p>The added session will be removed automatically {@linkplain MediaSession#release() when the
* session is released}. * session is released}.
* *
* <p>This method can be called from any thread.
*
* @param session A session to be added. * @param session A session to be added.
* @see #removeSession(MediaSession) * @see #removeSession(MediaSession)
* @see #getSessions() * @see #getSessions()
@ -268,8 +270,12 @@ public abstract class MediaSessionService extends Service {
// Session has returned for the first time. Register callbacks. // Session has returned for the first time. Register callbacks.
// TODO(b/191644474): Check whether the session is registered to multiple services. // TODO(b/191644474): Check whether the session is registered to multiple services.
MediaNotificationManager notificationManager = getMediaNotificationManager(); MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.addSession(session)); postOrRun(
mainHandler,
() -> {
notificationManager.addSession(session);
session.setListener(new MediaSessionListener()); 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. * Removes a {@link MediaSession} from this service. This is not necessary for most media apps.
* See <a href="#MultipleSessions">Supporting Multiple Sessions</a> for details. * See <a href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
* *
* <p>This method can be called from any thread.
*
* @param session A session to be removed. * @param session A session to be removed.
* @see #addSession(MediaSession) * @see #addSession(MediaSession)
* @see #getSessions() * @see #getSessions()
@ -288,13 +296,19 @@ public abstract class MediaSessionService extends Service {
sessions.remove(session.getId()); sessions.remove(session.getId());
} }
MediaNotificationManager notificationManager = getMediaNotificationManager(); MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.removeSession(session)); postOrRun(
mainHandler,
() -> {
notificationManager.removeSession(session);
session.clearListener(); session.clearListener();
});
} }
/** /**
* Returns the list of {@linkplain MediaSession sessions} that you've added to this service via * Returns the list of {@linkplain MediaSession sessions} that you've added to this service via
* {@link #addSession} or {@link #onGetSession(ControllerInfo)}. * {@link #addSession} or {@link #onGetSession(ControllerInfo)}.
*
* <p>This method can be called from any thread.
*/ */
public final List<MediaSession> getSessions() { public final List<MediaSession> getSessions() {
synchronized (lock) { 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 * Returns whether {@code session} has been added to this service via {@link #addSession} or
* {@link #onGetSession(ControllerInfo)}. * {@link #onGetSession(ControllerInfo)}.
*
* <p>This method can be called from any thread.
*/ */
public final boolean isSessionAdded(MediaSession session) { public final boolean isSessionAdded(MediaSession session) {
synchronized (lock) { synchronized (lock) {
@ -312,7 +328,11 @@ public abstract class MediaSessionService extends Service {
} }
} }
/** Sets the {@linkplain Listener listener}. */ /**
* Sets the {@linkplain Listener listener}.
*
* <p>This method can be called from any thread.
*/
@UnstableApi @UnstableApi
public final void setListener(Listener listener) { public final void setListener(Listener listener) {
synchronized (lock) { synchronized (lock) {
@ -320,7 +340,11 @@ public abstract class MediaSessionService extends Service {
} }
} }
/** Clears the {@linkplain Listener listener}. */ /**
* Clears the {@linkplain Listener listener}.
*
* <p>This method can be called from any thread.
*/
@UnstableApi @UnstableApi
public final void clearListener() { public final void clearListener() {
synchronized (lock) { 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}. * 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 * Override this method if this service also needs to handle actions other than {@link
* #SERVICE_INTERFACE}. * #SERVICE_INTERFACE}.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -378,6 +404,8 @@ public abstract class MediaSessionService extends Service {
* <p>The default implementation handles the incoming media button events. In this case, the * <p>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 * 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}. * service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -417,6 +445,8 @@ public abstract class MediaSessionService extends Service {
* Called when the service is no longer used and is being removed. * Called when the service is no longer used and is being removed.
* *
* <p>Override this method if you need your own clean up. * <p>Override this method if you need your own clean up.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -456,7 +486,7 @@ public abstract class MediaSessionService extends Service {
* @param session A session that needs notification update. * @param session A session that needs notification update.
*/ */
public void onUpdateNotification(MediaSession session) { public void onUpdateNotification(MediaSession session) {
setDefaultMethodCalled(true); defaultMethodCalled = true;
} }
/** /**
@ -483,13 +513,15 @@ public abstract class MediaSessionService extends Service {
* <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link * <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
* android.Manifest.permission#FOREGROUND_SERVICE}. * android.Manifest.permission#FOREGROUND_SERVICE}.
* *
* <p>This method will be called on the main thread.
*
* @param session A session that needs notification update. * @param session A session that needs notification update.
* @param startInForegroundRequired Whether the service is required to start in the foreground. * @param startInForegroundRequired Whether the service is required to start in the foreground.
*/ */
@UnstableApi @UnstableApi
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) { public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
onUpdateNotification(session); onUpdateNotification(session);
if (isDefaultMethodCalled()) { if (defaultMethodCalled) {
getMediaNotificationManager().updateNotification(session, startInForegroundRequired); getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
} }
} }
@ -498,6 +530,8 @@ public abstract class MediaSessionService extends Service {
* Sets the {@link MediaNotification.Provider} to customize notifications. * Sets the {@link MediaNotification.Provider} to customize notifications.
* *
* <p>This should be called before {@link #onCreate()} returns. * <p>This should be called before {@link #onCreate()} returns.
*
* <p>This method can be called from any thread.
*/ */
@UnstableApi @UnstableApi
protected final void setMediaNotificationProvider( protected final void setMediaNotificationProvider(
@ -514,11 +548,16 @@ public abstract class MediaSessionService extends Service {
} }
} }
/**
* Triggers notification update and handles {@code ForegroundServiceStartNotAllowedException}.
*
* <p>This method will be called on the main thread.
*/
/* package */ boolean onUpdateNotificationInternal( /* package */ boolean onUpdateNotificationInternal(
MediaSession session, boolean startInForegroundWhenPaused) { MediaSession session, boolean startInForegroundWhenPaused) {
try { try {
boolean startInForegroundRequired = boolean startInForegroundRequired =
shouldRunInForeground(session, startInForegroundWhenPaused); getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused);
onUpdateNotification(session, startInForegroundRequired); onUpdateNotification(session, startInForegroundRequired);
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
@ -531,14 +570,6 @@ public abstract class MediaSessionService extends Service {
return true; 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() { private MediaNotificationManager getMediaNotificationManager() {
synchronized (lock) { synchronized (lock) {
if (mediaNotificationManager == null) { 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) @RequiresApi(31)
private void onForegroundServiceStartNotAllowedException() { private void onForegroundServiceStartNotAllowedException() {
mainHandler.post( mainHandler.post(

View file

@ -70,7 +70,10 @@ import androidx.media3.common.MediaMetadata;
import androidx.media3.common.PlaybackParameters; import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.Rating; import androidx.media3.common.Rating;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.BundleableUtil;
import androidx.media3.common.util.Consumer; 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.ControllerInfo;
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition; import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
import androidx.media3.session.SessionCommand.CommandCode; import androidx.media3.session.SessionCommand.CommandCode;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -113,6 +117,9 @@ import java.util.concurrent.ExecutionException;
private final ConnectedControllersManager<IBinder> connectedControllersManager; private final ConnectedControllersManager<IBinder> connectedControllersManager;
private final Set<ControllerInfo> pendingControllers; private final Set<ControllerInfo> pendingControllers;
private ImmutableBiMap<TrackGroup, String> trackGroupIdMap;
private int nextUniqueTrackGroupIdPrefix;
public MediaSessionStub(MediaSessionImpl sessionImpl) { public MediaSessionStub(MediaSessionImpl sessionImpl) {
// Initialize members with params. // Initialize members with params.
this.sessionImpl = new WeakReference<>(sessionImpl); this.sessionImpl = new WeakReference<>(sessionImpl);
@ -120,6 +127,7 @@ import java.util.concurrent.ExecutionException;
connectedControllersManager = new ConnectedControllersManager<>(sessionImpl); connectedControllersManager = new ConnectedControllersManager<>(sessionImpl);
// ConcurrentHashMap has a bug in APIs 21-22 that can result in lost updates. // ConcurrentHashMap has a bug in APIs 21-22 that can result in lost updates.
pendingControllers = Collections.synchronizedSet(new HashSet<>()); pendingControllers = Collections.synchronizedSet(new HashSet<>());
trackGroupIdMap = ImmutableBiMap.of();
} }
public ConnectedControllersManager<IBinder> getConnectedControllersManager() { public ConnectedControllersManager<IBinder> getConnectedControllersManager() {
@ -493,6 +501,7 @@ import java.util.concurrent.ExecutionException;
// session/controller. // session/controller.
PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper(); PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
PlayerInfo playerInfo = playerWrapper.createPlayerInfoForBundling(); PlayerInfo playerInfo = playerWrapper.createPlayerInfoForBundling();
playerInfo = generateAndCacheUniqueTrackGroupIds(playerInfo);
ConnectionState state = ConnectionState state =
new ConnectionState( new ConnectionState(
MediaLibraryInfo.VERSION_INT, MediaLibraryInfo.VERSION_INT,
@ -1435,7 +1444,11 @@ import java.util.concurrent.ExecutionException;
sequenceNumber, sequenceNumber,
COMMAND_SET_TRACK_SELECTION_PARAMETERS, COMMAND_SET_TRACK_SELECTION_PARAMETERS,
sendSessionResultSuccess( 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))); librarySessionImpl.onUnsubscribeOnHandler(controller, parentId)));
} }
/* package */ PlayerInfo generateAndCacheUniqueTrackGroupIds(PlayerInfo playerInfo) {
ImmutableList<Tracks.Group> trackGroups = playerInfo.currentTracks.getGroups();
ImmutableList.Builder<Tracks.Group> updatedTrackGroups = ImmutableList.builder();
ImmutableBiMap.Builder<TrackGroup, String> 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. */ /** Common interface for code snippets to handle all incoming commands from the controller. */
private interface SessionTask<T, K extends MediaSessionImpl> { private interface SessionTask<T, K extends MediaSessionImpl> {
T run(K sessionImpl, ControllerInfo controller, int sequenceNumber); T run(K sessionImpl, ControllerInfo controller, int sequenceNumber);

View file

@ -16,7 +16,6 @@
package androidx.media3.session; package androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkArgument; 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.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat.QueueItem; import android.support.v4.media.session.MediaSessionCompat.QueueItem;
@ -27,11 +26,8 @@ import androidx.media3.common.Timeline;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* An immutable class to represent the current {@link Timeline} backed by {@linkplain QueueItem * 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 { /* package */ final class QueueTimeline extends Timeline {
public static final QueueTimeline DEFAULT = 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 static final Object FAKE_WINDOW_UID = new Object();
private final ImmutableList<MediaItem> mediaItems; private final ImmutableList<QueuedMediaItem> queuedMediaItems;
private final ImmutableMap<MediaItem, Long> mediaItemToQueueIdMap;
@Nullable private final MediaItem fakeMediaItem; @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( private QueueTimeline(
ImmutableList<MediaItem> mediaItems, ImmutableList<QueuedMediaItem> queuedMediaItems, @Nullable MediaItem fakeMediaItem) {
ImmutableMap<MediaItem, Long> mediaItemToQueueIdMap, this.queuedMediaItems = queuedMediaItems;
@Nullable MediaItem fakeMediaItem) {
this.mediaItems = mediaItems;
this.mediaItemToQueueIdMap = mediaItemToQueueIdMap;
this.fakeMediaItem = fakeMediaItem; this.fakeMediaItem = fakeMediaItem;
} }
/** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */ /** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */
public static QueueTimeline create(List<QueueItem> queue) { public static QueueTimeline create(List<QueueItem> queue) {
ImmutableList.Builder<MediaItem> mediaItemsBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<QueuedMediaItem> queuedMediaItemsBuilder = new ImmutableList.Builder<>();
ImmutableMap.Builder<MediaItem, Long> mediaItemToQueueIdMap = new ImmutableMap.Builder<>();
for (int i = 0; i < queue.size(); i++) { for (int i = 0; i < queue.size(); i++) {
QueueItem queueItem = queue.get(i); QueueItem queueItem = queue.get(i);
MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem); MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem);
mediaItemsBuilder.add(mediaItem); queuedMediaItemsBuilder.add(new QueuedMediaItem(mediaItem, queueItem.getQueueId()));
mediaItemToQueueIdMap.put(mediaItem, queueItem.getQueueId());
} }
return new QueueTimeline( return new QueueTimeline(queuedMediaItemsBuilder.build(), /* fakeMediaItem= */ null);
mediaItemsBuilder.build(), mediaItemToQueueIdMap.buildOrThrow(), /* 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. * @return The corresponding queue ID or {@link QueueItem#UNKNOWN_ID} if not known.
*/ */
public long getQueueId(int mediaItemIndex) { public long getQueueId(int mediaItemIndex) {
MediaItem mediaItem = getMediaItemAt(mediaItemIndex); return mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()
@Nullable Long queueId = mediaItemToQueueIdMap.get(mediaItem); ? queuedMediaItems.get(mediaItemIndex).queueId
return queueId == null ? QueueItem.UNKNOWN_ID : queueId; : QueueItem.UNKNOWN_ID;
} }
/** /**
@ -103,7 +90,7 @@ import java.util.Map;
* @return A new {@link QueueTimeline} reflecting the update. * @return A new {@link QueueTimeline} reflecting the update.
*/ */
public QueueTimeline copyWithFakeMediaItem(@Nullable MediaItem fakeMediaItem) { 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) { public QueueTimeline copyWithNewMediaItem(int replaceIndex, MediaItem newMediaItem) {
checkArgument( checkArgument(
replaceIndex < mediaItems.size() replaceIndex < queuedMediaItems.size()
|| (replaceIndex == mediaItems.size() && fakeMediaItem != null)); || (replaceIndex == queuedMediaItems.size() && fakeMediaItem != null));
if (replaceIndex == mediaItems.size()) { if (replaceIndex == queuedMediaItems.size()) {
return new QueueTimeline(mediaItems, mediaItemToQueueIdMap, newMediaItem); return new QueueTimeline(queuedMediaItems, newMediaItem);
} }
MediaItem oldMediaItem = mediaItems.get(replaceIndex); long queueId = queuedMediaItems.get(replaceIndex).queueId;
// Create the new play list. ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>(); queuedItemsBuilder.addAll(queuedMediaItems.subList(0, replaceIndex));
newMediaItemsBuilder.addAll(mediaItems.subList(0, replaceIndex)); queuedItemsBuilder.add(new QueuedMediaItem(newMediaItem, queueId));
newMediaItemsBuilder.add(newMediaItem); queuedItemsBuilder.addAll(queuedMediaItems.subList(replaceIndex + 1, queuedMediaItems.size()));
newMediaItemsBuilder.addAll(mediaItems.subList(replaceIndex + 1, mediaItems.size())); return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem);
// Update the map of items to queue IDs accordingly.
Map<MediaItem, Long> newMediaItemToQueueIdMap = new HashMap<>(mediaItemToQueueIdMap);
Long queueId = checkNotNull(newMediaItemToQueueIdMap.remove(oldMediaItem));
newMediaItemToQueueIdMap.put(newMediaItem, queueId);
return new QueueTimeline(
newMediaItemsBuilder.build(), ImmutableMap.copyOf(newMediaItemToQueueIdMap), fakeMediaItem);
} }
/** /**
@ -143,11 +124,13 @@ import java.util.Map;
* @return A new {@link QueueTimeline} reflecting the update. * @return A new {@link QueueTimeline} reflecting the update.
*/ */
public QueueTimeline copyWithNewMediaItems(int index, List<MediaItem> newMediaItems) { public QueueTimeline copyWithNewMediaItems(int index, List<MediaItem> newMediaItems) {
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
newMediaItemsBuilder.addAll(mediaItems.subList(0, index)); queuedItemsBuilder.addAll(queuedMediaItems.subList(0, index));
newMediaItemsBuilder.addAll(newMediaItems); for (int i = 0; i < newMediaItems.size(); i++) {
newMediaItemsBuilder.addAll(mediaItems.subList(index, mediaItems.size())); queuedItemsBuilder.add(new QueuedMediaItem(newMediaItems.get(i), QueueItem.UNKNOWN_ID));
return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem); }
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. * @return A new {@link QueueTimeline} reflecting the update.
*/ */
public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) { public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) {
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
newMediaItemsBuilder.addAll(mediaItems.subList(0, fromIndex)); queuedItemsBuilder.addAll(queuedMediaItems.subList(0, fromIndex));
newMediaItemsBuilder.addAll(mediaItems.subList(toIndex, mediaItems.size())); queuedItemsBuilder.addAll(queuedMediaItems.subList(toIndex, queuedMediaItems.size()));
return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem); return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem);
} }
/** /**
@ -173,50 +156,45 @@ import java.util.Map;
* @return A new {@link QueueTimeline} reflecting the update. * @return A new {@link QueueTimeline} reflecting the update.
*/ */
public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) { public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) {
List<MediaItem> list = new ArrayList<>(mediaItems); List<QueuedMediaItem> list = new ArrayList<>(queuedMediaItems);
Util.moveItems(list, fromIndex, toIndex, newIndex); Util.moveItems(list, fromIndex, toIndex, newIndex);
return new QueueTimeline( return new QueueTimeline(ImmutableList.copyOf(list), fakeMediaItem);
new ImmutableList.Builder<MediaItem>().addAll(list).build(),
mediaItemToQueueIdMap,
fakeMediaItem);
} }
/** /** Returns whether the timeline contains the given {@link MediaItem}. */
* Returns the media item index of the given media item in the timeline, or {@link C#INDEX_UNSET} public boolean contains(MediaItem mediaItem) {
* 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) {
if (mediaItem.equals(fakeMediaItem)) { if (mediaItem.equals(fakeMediaItem)) {
return mediaItems.size(); return true;
} }
int mediaItemIndex = mediaItems.indexOf(mediaItem); for (int i = 0; i < queuedMediaItems.size(); i++) {
return mediaItemIndex == -1 ? C.INDEX_UNSET : mediaItemIndex; if (mediaItem.equals(queuedMediaItems.get(i).mediaItem)) {
return true;
}
}
return false;
} }
@Nullable @Nullable
public MediaItem getMediaItemAt(int mediaItemIndex) { public MediaItem getMediaItemAt(int mediaItemIndex) {
if (mediaItemIndex >= 0 && mediaItemIndex < mediaItems.size()) { if (mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()) {
return mediaItems.get(mediaItemIndex); return queuedMediaItems.get(mediaItemIndex).mediaItem;
} }
return (mediaItemIndex == mediaItems.size()) ? fakeMediaItem : null; return (mediaItemIndex == queuedMediaItems.size()) ? fakeMediaItem : null;
} }
@Override @Override
public int getWindowCount() { public int getWindowCount() {
return mediaItems.size() + ((fakeMediaItem == null) ? 0 : 1); return queuedMediaItems.size() + ((fakeMediaItem == null) ? 0 : 1);
} }
@Override @Override
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
// TODO(b/149713425): Set duration if it's available from MediaMetadataCompat. // TODO(b/149713425): Set duration if it's available from MediaMetadataCompat.
MediaItem mediaItem; MediaItem mediaItem;
if (windowIndex == mediaItems.size() && fakeMediaItem != null) { if (windowIndex == queuedMediaItems.size() && fakeMediaItem != null) {
mediaItem = fakeMediaItem; mediaItem = fakeMediaItem;
} else { } else {
mediaItem = mediaItems.get(windowIndex); mediaItem = queuedMediaItems.get(windowIndex).mediaItem;
} }
return getWindow(window, mediaItem, windowIndex); return getWindow(window, mediaItem, windowIndex);
} }
@ -257,14 +235,13 @@ import java.util.Map;
return false; return false;
} }
QueueTimeline other = (QueueTimeline) obj; QueueTimeline other = (QueueTimeline) obj;
return Objects.equal(mediaItems, other.mediaItems) return Objects.equal(queuedMediaItems, other.queuedMediaItems)
&& Objects.equal(mediaItemToQueueIdMap, other.mediaItemToQueueIdMap)
&& Objects.equal(fakeMediaItem, other.fakeMediaItem); && Objects.equal(fakeMediaItem, other.fakeMediaItem);
} }
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hashCode(mediaItems, mediaItemToQueueIdMap, fakeMediaItem); return Objects.hashCode(queuedMediaItems, fakeMediaItem);
} }
private static Window getWindow(Window window, MediaItem mediaItem, int windowIndex) { private static Window getWindow(Window window, MediaItem mediaItem, int windowIndex) {
@ -285,4 +262,35 @@ import java.util.Map;
/* positionInFirstPeriodUs= */ 0); /* positionInFirstPeriodUs= */ 0);
return window; 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;
}
}
} }

View file

@ -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. * The extra bundle of a custom command. It will be {@link Bundle#EMPTY} for a predefined command.
*
* <p>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; public final Bundle customExtras;
@ -143,7 +147,9 @@ public final class SessionCommand implements Bundleable {
* Creates a custom command. * Creates a custom command.
* *
* @param action The action of this 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) { public SessionCommand(String action, Bundle extras) {
commandCode = COMMAND_CODE_CUSTOM; commandCode = COMMAND_CODE_CUSTOM;

View file

@ -36,6 +36,7 @@ import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
@ -785,6 +786,7 @@ public class DefaultMediaNotificationProviderTest {
when(mockMediaSession.getPlayer()).thenReturn(mockPlayer); when(mockMediaSession.getPlayer()).thenReturn(mockPlayer);
MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class); MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class);
when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl); when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl);
when(mockMediaSessionImpl.getApplicationHandler()).thenReturn(new Handler(Looper.myLooper()));
when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("https://example.test")); when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("https://example.test"));
return mockMediaSession; return mockMediaSession;
} }

View file

@ -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<TestService> 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<TestService> 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.
}
}
}

View file

@ -103,6 +103,7 @@ public class CommonConstants {
public static final String KEY_MAX_SEEK_TO_PREVIOUS_POSITION_MS = "maxSeekToPreviousPositionMs"; 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_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters";
public static final String KEY_CURRENT_TRACKS = "currentTracks"; public static final String KEY_CURRENT_TRACKS = "currentTracks";
public static final String KEY_AVAILABLE_COMMANDS = "availableCommands";
// SessionCompat arguments // SessionCompat arguments
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken"; public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";

View file

@ -980,6 +980,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
assertThat(TestUtils.equals(receivedSessionExtras.get(1), sessionExtras)).isTrue(); 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<String> receivedCommand = new AtomicReference<>();
AtomicReference<Bundle> 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 @Test
public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion() public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion()
throws Exception { throws Exception {
@ -1056,7 +1085,8 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
} }
@Test @Test
public void onMediaMetadataChanged_updatesLegacyMetadata_correctModelConversion() public void
onMediaMetadataChanged_withGetMetadataAndGetCurrentMediaItemCommand_updatesLegacyMetadata()
throws Exception { throws Exception {
int testItemIndex = 3; int testItemIndex = 3;
String testDisplayTitle = "displayTitle"; String testDisplayTitle = "displayTitle";
@ -1071,6 +1101,12 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
.setMediaId(testMediaItems.get(testItemIndex).mediaId) .setMediaId(testMediaItems.get(testItemIndex).mediaId)
.setMediaMetadata(testMediaMetadata) .setMediaMetadata(testMediaMetadata)
.build()); .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().setTimeline(new PlaylistTimeline(testMediaItems));
session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex); session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex);
session.getMockPlayer().setDuration(testDurationMs); session.getMockPlayer().setDuration(testDurationMs);
@ -1102,6 +1138,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId); assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId);
} }
@Test
public void onMediaMetadataChanged_withGetMetadataCommandOnly_updatesLegacyMetadata()
throws Exception {
int testItemIndex = 3;
String testDisplayTitle = "displayTitle";
List<MediaItem> 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<MediaMetadataCompat> 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 @Test
public void playlistChange() throws Exception { public void playlistChange() throws Exception {
AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>(); AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>();

View file

@ -326,6 +326,8 @@ public class MediaControllerListenerTest {
@Player.RepeatMode int testRepeatMode = Player.REPEAT_MODE_ALL; @Player.RepeatMode int testRepeatMode = Player.REPEAT_MODE_ALL;
int testCurrentAdGroupIndex = 33; int testCurrentAdGroupIndex = 33;
int testCurrentAdIndexInAdGroup = 11; int testCurrentAdIndexInAdGroup = 11;
Commands testCommands =
new Commands.Builder().addAllCommands().remove(Player.COMMAND_STOP).build();
AtomicInteger stateRef = new AtomicInteger(); AtomicInteger stateRef = new AtomicInteger();
AtomicReference<Timeline> timelineRef = new AtomicReference<>(); AtomicReference<Timeline> timelineRef = new AtomicReference<>();
AtomicReference<MediaMetadata> playlistMetadataRef = new AtomicReference<>(); AtomicReference<MediaMetadata> playlistMetadataRef = new AtomicReference<>();
@ -335,7 +337,8 @@ public class MediaControllerListenerTest {
AtomicInteger currentAdIndexInAdGroupRef = new AtomicInteger(); AtomicInteger currentAdIndexInAdGroupRef = new AtomicInteger();
AtomicBoolean shuffleModeEnabledRef = new AtomicBoolean(); AtomicBoolean shuffleModeEnabledRef = new AtomicBoolean();
AtomicInteger repeatModeRef = new AtomicInteger(); AtomicInteger repeatModeRef = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(7); AtomicReference<Commands> commandsRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(8);
MediaController controller = controllerTestRule.createController(remoteSession.getToken()); MediaController controller = controllerTestRule.createController(remoteSession.getToken());
threadTestRule threadTestRule
.getHandler() .getHandler()
@ -343,6 +346,12 @@ public class MediaControllerListenerTest {
() -> () ->
controller.addListener( controller.addListener(
new Player.Listener() { new Player.Listener() {
@Override
public void onAvailableCommandsChanged(Commands availableCommands) {
commandsRef.set(availableCommands);
latch.countDown();
}
@Override @Override
public void onAudioAttributesChanged(AudioAttributes attributes) { public void onAudioAttributesChanged(AudioAttributes attributes) {
audioAttributesRef.set(attributes); audioAttributesRef.set(attributes);
@ -402,6 +411,7 @@ public class MediaControllerListenerTest {
.setIsPlayingAd(true) .setIsPlayingAd(true)
.setCurrentAdGroupIndex(testCurrentAdGroupIndex) .setCurrentAdGroupIndex(testCurrentAdGroupIndex)
.setCurrentAdIndexInAdGroup(testCurrentAdIndexInAdGroup) .setCurrentAdIndexInAdGroup(testCurrentAdIndexInAdGroup)
.setAvailableCommands(testCommands)
.build(); .build();
remoteSession.setPlayer(playerConfig); remoteSession.setPlayer(playerConfig);
@ -415,6 +425,7 @@ public class MediaControllerListenerTest {
assertThat(currentAdIndexInAdGroupRef.get()).isEqualTo(testCurrentAdIndexInAdGroup); assertThat(currentAdIndexInAdGroupRef.get()).isEqualTo(testCurrentAdIndexInAdGroup);
assertThat(shuffleModeEnabledRef.get()).isEqualTo(testShuffleModeEnabled); assertThat(shuffleModeEnabledRef.get()).isEqualTo(testShuffleModeEnabled);
assertThat(repeatModeRef.get()).isEqualTo(testRepeatMode); assertThat(repeatModeRef.get()).isEqualTo(testRepeatMode);
assertThat(commandsRef.get()).isEqualTo(testCommands);
} }
@Test @Test
@ -1084,15 +1095,16 @@ public class MediaControllerListenerTest {
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(initialCurrentTracksRef.get()).isEqualTo(Tracks.EMPTY); assertThat(initialCurrentTracksRef.get()).isEqualTo(Tracks.EMPTY);
assertThat(changedCurrentTracksFromParamRef.get()).isEqualTo(currentTracks); assertThat(changedCurrentTracksFromParamRef.get().getGroups()).hasSize(2);
assertThat(changedCurrentTracksFromGetterRef.get()).isEqualTo(currentTracks); assertThat(changedCurrentTracksFromGetterRef.get())
.isEqualTo(changedCurrentTracksFromParamRef.get());
assertThat(capturedEvents).hasSize(2); assertThat(capturedEvents).hasSize(2);
assertThat(getEventsAsList(capturedEvents.get(0))).containsExactly(Player.EVENT_TRACKS_CHANGED); assertThat(getEventsAsList(capturedEvents.get(0))).containsExactly(Player.EVENT_TRACKS_CHANGED);
assertThat(getEventsAsList(capturedEvents.get(1))) assertThat(getEventsAsList(capturedEvents.get(1)))
.containsExactly(Player.EVENT_IS_LOADING_CHANGED); .containsExactly(Player.EVENT_IS_LOADING_CHANGED);
assertThat(changedCurrentTracksFromOnEvents).hasSize(2); assertThat(changedCurrentTracksFromOnEvents).hasSize(2);
assertThat(changedCurrentTracksFromOnEvents.get(0)).isEqualTo(currentTracks); assertThat(changedCurrentTracksFromOnEvents.get(0).getGroups()).hasSize(2);
assertThat(changedCurrentTracksFromOnEvents.get(1)).isEqualTo(currentTracks); assertThat(changedCurrentTracksFromOnEvents.get(1).getGroups()).hasSize(2);
// Assert that an equal instance is not re-sent over the binder. // Assert that an equal instance is not re-sent over the binder.
assertThat(changedCurrentTracksFromOnEvents.get(0)) assertThat(changedCurrentTracksFromOnEvents.get(0))
.isSameInstanceAs(changedCurrentTracksFromOnEvents.get(1)); .isSameInstanceAs(changedCurrentTracksFromOnEvents.get(1));

View file

@ -42,6 +42,7 @@ import androidx.media3.common.IllegalSeekPositionException;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Metadata;
import androidx.media3.common.PlaybackException; import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters; import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player; import androidx.media3.common.Player;
@ -50,6 +51,7 @@ import androidx.media3.common.Rating;
import androidx.media3.common.StarRating; import androidx.media3.common.StarRating;
import androidx.media3.common.Timeline; import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroup; import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks; import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize; import androidx.media3.common.VideoSize;
@ -427,7 +429,7 @@ public class MediaControllerTest {
assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs); assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs);
assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs); assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs);
assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters); assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters);
assertThat(currentTracksRef.get()).isEqualTo(currentTracks); assertThat(currentTracksRef.get().getGroups()).hasSize(2);
assertTimelineMediaItemsEquals(timelineRef.get(), timeline); assertTimelineMediaItemsEquals(timelineRef.get(), timeline);
assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex); assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex);
assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem); assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem);
@ -1211,6 +1213,118 @@ public class MediaControllerTest {
assertThat(mediaMetadata).isEqualTo(testMediaMetadata); 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 @Test
public void public void
setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly() setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly()

View file

@ -78,6 +78,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.ext.truth.os.BundleSubject; import androidx.test.ext.truth.os.BundleSubject;
import androidx.test.filters.MediumTest; import androidx.test.filters.MediumTest;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Range; import com.google.common.collect.Range;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -414,6 +415,41 @@ public class MediaControllerWithMediaSessionCompatTest {
assertThat(timelineRef.get().getPeriodCount()).isEqualTo(0); 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<Timeline> timelineFromParamRef = new AtomicReference<>();
AtomicReference<Timeline> 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<MediaItem> mediaItems = MediaTestUtils.createMediaItems(/* size= */ 2);
Timeline testTimeline =
MediaTestUtils.createTimeline(
ImmutableList.copyOf(Iterables.concat(mediaItems, mediaItems)));
List<QueueItem> 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 @Test
public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception { public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);

View file

@ -24,13 +24,21 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.content.Context; import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; 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.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.common.util.Util;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.MainLooperTestRule;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -248,4 +256,58 @@ public class MediaSessionAndControllerTest {
player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, 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));
}
} }

View file

@ -18,6 +18,7 @@ package androidx.media3.session;
import static androidx.media3.common.Player.COMMAND_GET_TRACKS; 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.ACTION_MEDIA3_SESSION;
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; 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_PERCENTAGE;
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_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 = player.trackSelectionParameters =
TrackSelectionParameters.fromBundle(trackSelectionParametersBundle); TrackSelectionParameters.fromBundle(trackSelectionParametersBundle);
} }
@Nullable Bundle availableCommandsBundle = config.getBundle(KEY_AVAILABLE_COMMANDS);
if (availableCommandsBundle != null) {
player.commands = Player.Commands.CREATOR.fromBundle(availableCommandsBundle);
}
return player; return player;
} }

View file

@ -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.ACTION_MEDIA3_SESSION;
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; 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_PERCENTAGE;
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION; import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION; import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
@ -742,6 +743,12 @@ public class RemoteMediaSession {
return this; return this;
} }
@CanIgnoreReturnValue
public MockPlayerConfigBuilder setAvailableCommands(Player.Commands availableCommands) {
bundle.putBundle(KEY_AVAILABLE_COMMANDS, availableCommands.toBundle());
return this;
}
public Bundle build() { public Bundle build() {
return bundle; return bundle;
} }

View file

@ -20,6 +20,7 @@ dependencies {
api 'androidx.test.ext:truth:' + androidxTestTruthVersion api 'androidx.test.ext:truth:' + androidxTestTruthVersion
api 'junit:junit:' + junitVersion api 'junit:junit:' + junitVersion
api 'com.google.truth:truth:' + truthVersion 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-qual:' + checkerframeworkVersion
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion

View file

@ -24,6 +24,7 @@ def addMissingAarTypeToXml(xml) {
"com.google.ads.interactivemedia.v3:interactivemedia", "com.google.ads.interactivemedia.v3:interactivemedia",
"com.google.guava:guava", "com.google.guava:guava",
"com.google.truth:truth", "com.google.truth:truth",
"com.google.truth.extensions:truth-java8-extension",
"com.squareup.okhttp3:okhttp", "com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:mockwebserver", "com.squareup.okhttp3:mockwebserver",
"org.mockito:mockito-core", "org.mockito:mockito-core",
@ -77,6 +78,11 @@ def addMissingAarTypeToXml(xml) {
(isProjectLibrary (isProjectLibrary
|| aar_dependencies.contains(dependencyName)) || aar_dependencies.contains(dependencyName))
if (!hasJar && !hasAar) { 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( throw new IllegalStateException(
dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle") dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle")
} }