Commit 3c01488f by Rohit Kumar Singh Committed by GitHub

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

1.0.1
parents 2ca90501 4f0b30b8
Showing with 1490 additions and 329 deletions
...@@ -20,6 +20,7 @@ body: ...@@ -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: ...@@ -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
......
...@@ -36,7 +36,7 @@ In case your question is related to a piece of media: ...@@ -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
......
...@@ -23,6 +23,21 @@ We will also consider high quality pull requests. These should merge ...@@ -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
......
# 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
......
...@@ -12,8 +12,8 @@ ...@@ -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.
......
...@@ -27,6 +27,7 @@ android { ...@@ -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 {
......
...@@ -21,7 +21,7 @@ ...@@ -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>
......
...@@ -26,6 +26,7 @@ import android.view.ViewGroup ...@@ -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() { ...@@ -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()
......
...@@ -135,6 +135,7 @@ class PlayerActivity : AppCompatActivity() { ...@@ -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 {
......
...@@ -61,6 +61,6 @@ manual steps. ...@@ -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
...@@ -28,7 +28,7 @@ import androidx.media3.effect.MatrixTransformation; ...@@ -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.
*/ */
......
...@@ -52,12 +52,12 @@ public final class AuxEffectInfo { ...@@ -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;
......
...@@ -35,7 +35,8 @@ import java.util.UUID; ...@@ -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>
* *
......
...@@ -20,6 +20,7 @@ import android.view.Surface; ...@@ -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 { ...@@ -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));
} }
......
...@@ -29,11 +29,11 @@ public final class MediaLibraryInfo { ...@@ -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 { ...@@ -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;
......
...@@ -152,8 +152,9 @@ public class PlaybackException extends Exception implements Bundleable { ...@@ -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. */
......
...@@ -3355,7 +3355,8 @@ public abstract class SimpleBasePlayer extends BasePlayer { ...@@ -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);
} }
......
...@@ -61,8 +61,9 @@ import java.util.List; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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 {
/** /**
......
...@@ -191,6 +191,18 @@ public final class Tracks implements Bundleable { ...@@ -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) {
......
...@@ -142,7 +142,7 @@ public final class GlUtil { ...@@ -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 { ...@@ -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 { ...@@ -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.
......
...@@ -375,8 +375,8 @@ public interface HttpDataSource extends DataSource { ...@@ -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 { ...@@ -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,
......
...@@ -128,6 +128,4 @@ GL rendering mode has better performance, so should be preferred ...@@ -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
...@@ -115,12 +115,10 @@ then implement your own logic to use the renderer for a given track. ...@@ -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
...@@ -100,6 +100,4 @@ player, then implement your own logic to use the renderer for a given track. ...@@ -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
...@@ -104,6 +104,4 @@ player, then implement your own logic to use the renderer for a given track. ...@@ -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
...@@ -141,6 +141,4 @@ GL rendering mode has better performance, so should be preferred. ...@@ -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
...@@ -128,8 +128,9 @@ import java.util.List; ...@@ -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; ...@@ -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 {
/** /**
......
...@@ -2673,7 +2673,8 @@ import java.util.concurrent.TimeoutException; ...@@ -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);
......
...@@ -1239,7 +1239,8 @@ import java.util.concurrent.atomic.AtomicBoolean; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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) {
......
...@@ -47,9 +47,12 @@ import java.lang.annotation.Target; ...@@ -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 {
......
...@@ -189,6 +189,8 @@ public interface AudioSink { ...@@ -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;
......
...@@ -80,7 +80,7 @@ public class DefaultAudioTrackBufferSizeProvider ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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) {
......
...@@ -105,6 +105,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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);
......
...@@ -136,9 +136,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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")
......
...@@ -471,6 +471,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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}.
* *
......
...@@ -642,14 +642,22 @@ public abstract class MediaCodecRenderer extends BaseRenderer { ...@@ -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 { ...@@ -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();
......
...@@ -71,17 +71,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -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
......
...@@ -214,6 +214,115 @@ public class MediaCodecRendererTest { ...@@ -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();
......
...@@ -236,9 +236,12 @@ public class DefaultDashChunkSource implements DashChunkSource { ...@@ -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 { ...@@ -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);
......
...@@ -26,7 +26,7 @@ locally. Instructions for doing this can be found in the [top level README][]. ...@@ -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.
......
...@@ -273,7 +273,7 @@ public final class ImaAdsLoader implements AdsLoader { ...@@ -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
......
...@@ -49,6 +49,7 @@ import androidx.media3.common.util.Log; ...@@ -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; ...@@ -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(
......
...@@ -518,7 +518,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -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,7 +643,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -644,7 +643,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Override @Override
public void onPlaybackError(RtspPlaybackException error) { public void onPlaybackError(RtspPlaybackException error) {
playbackException = error; if (error instanceof RtspMediaSource.RtspUdpUnsupportedTransportException && !isUsingRtpTcp) {
// Retry playback with TCP if we receive RtspUdpUnsupportedTransportException, and we are
// not already using TCP. Retrying will setup new loadables.
retryWithRtpTcp();
} else {
playbackException = error;
}
} }
@Override @Override
...@@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; ...@@ -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
......
...@@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource { ...@@ -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 { ...@@ -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;
......
...@@ -453,4 +453,77 @@ public final class RtspClientTest { ...@@ -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);
}
} }
...@@ -16,6 +16,7 @@ ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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);
} }
...@@ -130,6 +145,22 @@ public final class HevcConfig { ...@@ -130,6 +145,22 @@ public final class HevcConfig {
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.
* *
* <p>See {@link Format#codecs}. * <p>See {@link Format#codecs}.
...@@ -142,12 +173,18 @@ public final class HevcConfig { ...@@ -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;
} }
} }
...@@ -19,6 +19,8 @@ import static java.lang.Math.min; ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -622,7 +644,10 @@ public final class NalUnitUtil {
seqParameterSetId, seqParameterSetId,
frameWidth, frameWidth,
frameHeight, frameHeight,
pixelWidthHeightRatio); pixelWidthHeightRatio,
colorSpace,
colorRange,
colorTransfer);
} }
/** /**
......
...@@ -177,6 +177,9 @@ import java.util.List; ...@@ -177,6 +177,9 @@ import java.util.List;
public static final int TYPE_ddts = 0x64647473; public static final int TYPE_ddts = 0x64647473;
@SuppressWarnings("ConstantCaseForConstants") @SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_udts = 0x75647473;
@SuppressWarnings("ConstantCaseForConstants")
public static final int TYPE_tfdt = 0x74666474; public static final int TYPE_tfdt = 0x74666474;
@SuppressWarnings("ConstantCaseForConstants") @SuppressWarnings("ConstantCaseForConstants")
......
...@@ -1164,6 +1164,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -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; ...@@ -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,26 +1265,33 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -1252,26 +1265,33 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
} }
} else if (childAtomType == Atom.TYPE_colr) { } else if (childAtomType == Atom.TYPE_colr) {
int colorType = parent.readInt(); // Only modify these values if they have not been previously established by the bitstream.
if (colorType == TYPE_nclx || colorType == TYPE_nclc) { // If 'Atom.TYPE_hvcC' atom or 'Atom.TYPE_vpcC' is available, they will take precedence and
// For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and // overwrite any existing values.
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html. if (colorSpace == Format.NO_VALUE
int colorPrimaries = parent.readUnsignedShort(); && colorRange == Format.NO_VALUE
int transferCharacteristics = parent.readUnsignedShort(); && colorTransfer == Format.NO_VALUE) {
parent.skipBytes(2); // matrix_coefficients. int colorType = parent.readInt();
if (colorType == TYPE_nclx || colorType == TYPE_nclc) {
// Only try and read full_range_flag if the box is long enough. It should be present in // For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
// all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html.
// device cameras record videos with type=nclx without this final flag (and therefore int colorPrimaries = parent.readUnsignedShort();
// size=18): https://github.com/google/ExoPlayer/issues/9332 int transferCharacteristics = parent.readUnsignedShort();
boolean fullRangeFlag = parent.skipBytes(2); // matrix_coefficients.
childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0;
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries); // Only try and read full_range_flag if the box is long enough. It should be present in
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED; // all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some
colorTransfer = // device cameras record videos with type=nclx without this final flag (and therefore
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics); // size=18): https://github.com/google/ExoPlayer/issues/9332
} else { boolean fullRangeFlag =
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType)); childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0;
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
colorTransfer =
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
} else {
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
}
} }
} }
childPosition += childAtomSize; childPosition += childAtomSize;
...@@ -1560,7 +1580,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -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)
......
...@@ -194,6 +194,9 @@ public final class NalUnitUtilTest { ...@@ -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() {
......
...@@ -43,6 +43,8 @@ dependencies { ...@@ -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
} }
......
...@@ -89,8 +89,8 @@ import androidx.media3.common.util.Util; ...@@ -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,
......
...@@ -33,8 +33,6 @@ import android.app.NotificationManager; ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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);
} }
} }
} }
......
...@@ -1795,7 +1795,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -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; ...@@ -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; ...@@ -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(
......
...@@ -35,6 +35,10 @@ public final class MediaNotification { ...@@ -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 { ...@@ -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.
......
...@@ -50,6 +50,8 @@ import java.util.concurrent.TimeoutException; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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(
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { new Handler(session.getPlayer().getApplicationLooper()),
@Nullable SessionCommand customCommand = null; () -> {
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM mainExecutor.execute(
&& command.customAction.equals(action)) { () -> sendCustomCommandIfCommandIsAvailable(mediaController, 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; ...@@ -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(
MediaNotification mediaNotification = new Handler(session.getPlayer().getApplicationLooper()),
this.mediaNotificationProvider.createNotification( () -> {
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback); MediaNotification mediaNotification =
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); this.mediaNotificationProvider.createNotification(
session, customLayout, actionFactory, callback);
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; ...@@ -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; ...@@ -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; ...@@ -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);
} }
......
...@@ -701,6 +701,9 @@ public class MediaSession { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 {
/** /**
......
...@@ -35,6 +35,7 @@ import static androidx.media3.common.Player.STATE_ENDED; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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();
} }
...@@ -833,6 +906,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -833,6 +906,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable. @SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
private static void setMediaButtonReceiver(
MediaSessionCompat sessionCompat, @Nullable PendingIntent mediaButtonReceiverIntent) {
sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent);
}
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) { private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) {
sessionCompat.setQueue(queue); sessionCompat.setQueue(queue);
} }
...@@ -988,6 +1067,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -988,6 +1067,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} }
@Override @Override
public void sendCustomCommand(int seq, SessionCommand command, Bundle args) {
sessionImpl.getSessionCompat().sendSessionEvent(command.customAction, args);
}
@Override
public void onPlayWhenReadyChanged( public void onPlayWhenReadyChanged(
int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason) int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason)
throws RemoteException { throws RemoteException {
...@@ -1237,11 +1321,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; ...@@ -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; ...@@ -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);
}
}
} }
...@@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi; ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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(
session.setListener(new MediaSessionListener()); mainHandler,
() -> {
notificationManager.addSession(session);
session.setListener(new MediaSessionListener());
});
} }
} }
...@@ -277,6 +283,8 @@ public abstract class MediaSessionService extends Service { ...@@ -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 { ...@@ -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(
session.clearListener(); mainHandler,
() -> {
notificationManager.removeSession(session);
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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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(
......
...@@ -70,7 +70,10 @@ import androidx.media3.common.MediaMetadata; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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; ...@@ -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);
......
...@@ -123,6 +123,10 @@ public final class SessionCommand implements Bundleable { ...@@ -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 { ...@@ -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;
......
...@@ -36,6 +36,7 @@ import android.content.Context; ...@@ -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 { ...@@ -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;
} }
......
/*
* 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.
}
}
}
...@@ -103,6 +103,7 @@ public class CommonConstants { ...@@ -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";
......
...@@ -981,6 +981,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { ...@@ -981,6 +981,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
} }
@Test @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
public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion() public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion()
throws Exception { throws Exception {
int testItemIndex = 3; int testItemIndex = 3;
...@@ -1056,8 +1085,9 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { ...@@ -1056,8 +1085,9 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
} }
@Test @Test
public void onMediaMetadataChanged_updatesLegacyMetadata_correctModelConversion() public void
throws Exception { onMediaMetadataChanged_withGetMetadataAndGetCurrentMediaItemCommand_updatesLegacyMetadata()
throws Exception {
int testItemIndex = 3; int testItemIndex = 3;
String testDisplayTitle = "displayTitle"; String testDisplayTitle = "displayTitle";
long testDurationMs = 30_000; long testDurationMs = 30_000;
...@@ -1071,6 +1101,12 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { ...@@ -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);
...@@ -1103,6 +1139,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { ...@@ -1103,6 +1139,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
} }
@Test @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
public void playlistChange() throws Exception { public void playlistChange() throws Exception {
AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>(); AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
......
...@@ -326,6 +326,8 @@ public class MediaControllerListenerTest { ...@@ -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 { ...@@ -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()
...@@ -344,6 +347,12 @@ public class MediaControllerListenerTest { ...@@ -344,6 +347,12 @@ public class MediaControllerListenerTest {
controller.addListener( controller.addListener(
new Player.Listener() { new Player.Listener() {
@Override @Override
public void onAvailableCommandsChanged(Commands availableCommands) {
commandsRef.set(availableCommands);
latch.countDown();
}
@Override
public void onAudioAttributesChanged(AudioAttributes attributes) { public void onAudioAttributesChanged(AudioAttributes attributes) {
audioAttributesRef.set(attributes); audioAttributesRef.set(attributes);
latch.countDown(); latch.countDown();
...@@ -402,6 +411,7 @@ public class MediaControllerListenerTest { ...@@ -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 { ...@@ -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 { ...@@ -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));
......
...@@ -42,6 +42,7 @@ import androidx.media3.common.IllegalSeekPositionException; ...@@ -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; ...@@ -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 { ...@@ -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);
...@@ -1212,6 +1214,118 @@ public class MediaControllerTest { ...@@ -1212,6 +1214,118 @@ public class MediaControllerTest {
} }
@Test @Test
public void getCurrentTracks_hasEqualTrackGroupsForEqualGroupsInPlayer() throws Exception {
// Include metadata in Format to ensure the track group can't be fully bundled.
Tracks initialPlayerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1]),
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
Tracks updatedPlayerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
/* adaptiveSupported= */ true,
/* trackSupport= */ new int[] {C.FORMAT_HANDLED},
/* trackSelected= */ new boolean[] {true}),
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("3").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
Bundle playerConfig =
new RemoteMediaSession.MockPlayerConfigBuilder()
.setCurrentTracks(initialPlayerTracks)
.build();
remoteSession.setPlayer(playerConfig);
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
CountDownLatch trackChangedEvent = new CountDownLatch(1);
threadTestRule
.getHandler()
.postAndSync(
() ->
controller.addListener(
new Player.Listener() {
@Override
public void onTracksChanged(Tracks tracks) {
trackChangedEvent.countDown();
}
}));
Tracks initialControllerTracks =
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
// Do something unrelated first to ensure tracks are correctly kept even after multiple updates.
remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_READY);
remoteSession.getMockPlayer().notifyTracksChanged(updatedPlayerTracks);
trackChangedEvent.await();
Tracks updatedControllerTracks =
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
assertThat(initialControllerTracks.getGroups()).hasSize(2);
assertThat(updatedControllerTracks.getGroups()).hasSize(2);
assertThat(initialControllerTracks.getGroups().get(1).getMediaTrackGroup())
.isEqualTo(updatedControllerTracks.getGroups().get(0).getMediaTrackGroup());
}
@Test
public void getCurrentTracksAndTrackOverrides_haveEqualTrackGroupsForEqualGroupsInPlayer()
throws Exception {
// Include metadata in Format to ensure the track group can't be fully bundled.
TrackGroup playerTrackGroupForOverride =
new TrackGroup(new Format.Builder().setMetadata(new Metadata()).setId("2").build());
Tracks playerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1]),
new Tracks.Group(
playerTrackGroupForOverride,
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
TrackSelectionParameters trackSelectionParameters =
TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
.buildUpon()
.addOverride(
new TrackSelectionOverride(playerTrackGroupForOverride, /* trackIndex= */ 0))
.build();
Bundle playerConfig =
new RemoteMediaSession.MockPlayerConfigBuilder()
.setCurrentTracks(playerTracks)
.setTrackSelectionParameters(trackSelectionParameters)
.build();
remoteSession.setPlayer(playerConfig);
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
Tracks controllerTracks = threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
TrackSelectionParameters controllerTrackSelectionParameters =
threadTestRule.getHandler().postAndSync(controller::getTrackSelectionParameters);
TrackGroup controllerTrackGroup = controllerTracks.getGroups().get(1).getMediaTrackGroup();
assertThat(controllerTrackSelectionParameters.overrides)
.containsExactly(
controllerTrackGroup,
new TrackSelectionOverride(controllerTrackGroup, /* trackIndex= */ 0));
}
@Test
public void public void
setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly() setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly()
throws Exception { throws Exception {
......
...@@ -78,6 +78,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; ...@@ -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;
...@@ -415,6 +416,41 @@ public class MediaControllerWithMediaSessionCompatTest { ...@@ -415,6 +416,41 @@ public class MediaControllerWithMediaSessionCompatTest {
} }
@Test @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
public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception { public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception {
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Timeline> timelineRef = new AtomicReference<>(); AtomicReference<Timeline> timelineRef = new AtomicReference<>();
......
...@@ -24,13 +24,21 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; ...@@ -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 { ...@@ -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));
}
} }
...@@ -18,6 +18,7 @@ package androidx.media3.session; ...@@ -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 { ...@@ -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;
} }
......
...@@ -17,6 +17,7 @@ package androidx.media3.session; ...@@ -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 { ...@@ -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;
} }
......
...@@ -20,6 +20,7 @@ dependencies { ...@@ -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
......
...@@ -24,6 +24,7 @@ def addMissingAarTypeToXml(xml) { ...@@ -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) { ...@@ -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")
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment