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

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

View file

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

View file

@ -36,7 +36,7 @@ In case your question is related to a piece of media:
- Authentication HTTP headers
Don't forget to check ExoPlayer's supported formats and devices, if applicable
(https://exoplayer.dev/supported-formats.html).
(https://developer.android.com/guide/topics/media/exoplayer/supported-formats).
If there's something you don't want to post publicly, please submit the issue,
then email the link/bug report to dev.exoplayer@gmail.com using a subject in the

View file

@ -23,6 +23,21 @@ We will also consider high quality pull requests. These should merge
into the `main` branch. Before a pull request can be accepted you must submit
a Contributor License Agreement, as described below.
### Code style
We follow the
[Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)
and use [`google-java-format`](https://github.com/google/google-java-format) to
automatically reformat the code. Please consider auto-formatting your changes
before opening a PR (we will otherwise do this ourselves before merging). You
can use the various IDE integrations available, or bulk-reformat all the changes
you made on top of `main` using
[`google-java-format-diff.py`](https://github.com/google/google-java-format/blob/master/scripts/google-java-format-diff.py):
```shell
$ git diff -U0 main... | google-java-format-diff.py -p1 -i
```
## Contributor license agreement
Contributions to any Google project must be accompanied by a Contributor

View file

@ -1,5 +1,44 @@
# Release notes
### 1.0.1 (2023-04-18)
This release corresponds to the
[ExoPlayer 2.18.6 release](https://github.com/google/ExoPlayer/releases/tag/r2.18.6).
* Core library:
* Reset target live stream override when seeking to default position
([#11051](https://github.com/google/ExoPlayer/pull/11051)).
* Fix bug where empty sample streams in the media could cause playback to
be stuck.
* Session:
* Fix bug where multiple identical queue items published by a legacy
`MediaSessionCompat` result in an exception in `MediaController`
([#290](https://github.com/androidx/media/issues/290)).
* Add missing forwarding of `MediaSession.broadcastCustomCommand` to the
legacy `MediaControllerCompat.Callback.onSessionEvent`
([#293](https://github.com/androidx/media/issues/293)).
* Fix bug where calling `MediaSession.setPlayer` doesn't update the
available commands.
* Fix issue that `TrackSelectionOverride` instances sent from a
`MediaController` are ignored if they reference a group with
`Format.metadata`
([#296](https://github.com/androidx/media/issues/296)).
* Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be
available to access metadata via the legacy `MediaSessionCompat`.
* Fix issue where `MediaSession` instances on a background thread cause
crashes when used in `MediaSessionService`
([#318](https://github.com/androidx/media/issues/318)).
* Fix issue where a media button receiver was declared by the library
without the app having intended this
([#314](https://github.com/androidx/media/issues/314)).
* DASH:
* Fix handling of empty segment timelines
([#11014](https://github.com/google/ExoPlayer/issues/11014)).
* RTSP:
* Retry with TCP if RTSP Setup with UDP fails with RTSP Error 461
UnsupportedTransport
([#11069](https://github.com/google/ExoPlayer/issues/11069)).
### 1.0.0 (2023-03-22)
This release corresponds to the

View file

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

View file

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

View file

@ -21,7 +21,7 @@
<string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string>
<string name="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>

View file

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

View file

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

View file

@ -61,6 +61,6 @@ manual steps.
(this will only appear if the AAR is present), then build and run the demo
app and select a MediaPipe-based effect.
[Transformer]: https://exoplayer.dev/transforming-media.html
[Transformer]: https://developer.android.com/guide/topics/media/transforming-media
[MediaPipe]: https://google.github.io/mediapipe/
[build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html

View file

@ -28,7 +28,7 @@ import androidx.media3.effect.MatrixTransformation;
*/
/* package */ final class MatrixTransformationFactory {
/**
* Returns a {@link MatrixTransformation} that rescales the frames over the first {@value
* Returns a {@link MatrixTransformation} that rescales the frames over the first {@link
* #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases
* linearly in size from a single point to filling the full output frame.
*/

View file

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

View file

@ -35,7 +35,8 @@ import java.util.UUID;
*
* <p>When building formats, populate all fields whose values are known and relevant to the type of
* 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>
*

View file

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

View file

@ -29,11 +29,11 @@ public final class MediaLibraryInfo {
/** The version of the library expressed as a string, for example "1.2.3" or "1.2.3-beta01". */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa.
public static final String VERSION = "1.0.0";
public static final String VERSION = "1.0.1";
/** The version of the library expressed as {@code TAG + "/" + VERSION}. */
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0";
public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.1";
/**
* The version of the library expressed as an integer, for example 1002003300.
@ -47,7 +47,7 @@ public final class MediaLibraryInfo {
* (123-045-006-3-00).
*/
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
public static final int VERSION_INT = 1_000_000_3_00;
public static final int VERSION_INT = 1_000_001_3_00;
/** Whether the library was compiled with {@link Assertions} checks enabled. */
public static final boolean ASSERTIONS_ENABLED = true;

View file

@ -152,8 +152,9 @@ public class PlaybackException extends Exception implements Bundleable {
* Caused by the player trying to access cleartext HTTP traffic (meaning http:// rather than
* 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
* troubleshooting topic</a>.
* <p>See <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;
/** Caused by reading data out of the data bound. */

View file

@ -3355,7 +3355,8 @@ public abstract class SimpleBasePlayer extends BasePlayer {
"Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n"
+ "Expected thread: '%s'\n"
+ "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
+ "See https://developer.android.com/guide/topics/media/issues/"
+ "player-accessed-on-wrong-thread",
Thread.currentThread().getName(), applicationLooper.getThread().getName());
throw new IllegalStateException(message);
}

View file

@ -61,8 +61,9 @@ import java.util.List;
*
* <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
* single file">
* <p style="align:center"><img
* 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.
* The window spans the whole period, indicating that all parts of the media are available for
@ -71,8 +72,9 @@ import java.util.List;
*
* <h2>Playlist of media files or on-demand streams</h2>
*
* <p style="align:center"><img src="doc-files/timeline-playlist.svg" alt="Example timeline for a
* playlist of files">
* <p style="align:center"><img
* 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,
* each with its own window. Each window spans the whole of the corresponding period, and typically
@ -82,8 +84,9 @@ import java.util.List;
*
* <h2 id="live-limited">Live stream with limited availability</h2>
*
* <p style="align:center"><img src="doc-files/timeline-live-limited.svg" alt="Example timeline for
* a live stream with limited availability">
* <p style="align:center"><img
* 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
* continually extending as more content is broadcast. If content only remains available for a
@ -95,8 +98,9 @@ import java.util.List;
*
* <h2>Live stream with indefinite availability</h2>
*
* <p style="align:center"><img src="doc-files/timeline-live-indefinite.svg" alt="Example timeline
* for a live stream with indefinite availability">
* <p style="align:center"><img
* 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
* href="#live-limited">Live stream with limited availability</a> case, except that the window
@ -105,8 +109,9 @@ import java.util.List;
*
* <h2 id="live-multi-period">Live stream with multiple periods</h2>
*
* <p style="align:center"><img src="doc-files/timeline-live-multi-period.svg" alt="Example timeline
* for a live stream with multiple periods">
* <p style="align:center"><img
* 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
* at content boundaries. This case is similar to the <a href="#live-limited">Live stream with
@ -115,8 +120,9 @@ import java.util.List;
*
* <h2>On-demand stream followed by live stream</h2>
*
* <p style="align:center"><img src="doc-files/timeline-advanced.svg" alt="Example timeline for an
* on-demand stream followed by a live stream">
* <p style="align:center"><img
* 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
* stream</a> and <a href="#multi-period">Live stream with multiple periods</a> cases. When playback
@ -125,12 +131,15 @@ import java.util.List;
*
* <h2 id="single-file-midrolls">On-demand stream with mid-roll ads</h2>
*
* <p style="align:center"><img src="doc-files/timeline-single-file-midrolls.svg" alt="Example
* timeline for an on-demand stream with mid-roll ad groups">
* <p style="align:center"><img
* 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
* 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 {
/**

View file

@ -191,6 +191,18 @@ public final class Tracks implements Bundleable {
return mediaTrackGroup.type;
}
/**
* Copies the {@code Group} with a new {@link TrackGroup#id}.
*
* @param groupId The new {@link TrackGroup#id}
* @return The copied {@code Group}.
*/
@UnstableApi
public Group copyWithId(String groupId) {
return new Group(
mediaTrackGroup.copyWithId(groupId), adaptiveSupported, trackSupport, trackSelected);
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {

View file

@ -142,7 +142,7 @@ public final class GlUtil {
}
/**
* Returns whether creating a GL context with {@value #EXTENSION_PROTECTED_CONTENT} is possible.
* Returns whether creating a GL context with {@link #EXTENSION_PROTECTED_CONTENT} is possible.
*
* <p>If {@code true}, the device supports a protected output path for DRM content when using GL.
*/
@ -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
* surfaces in a call to {@link EGL14#eglMakeCurrent(EGLDisplay, EGLSurface, EGLSurface,
@ -187,7 +187,7 @@ public final class GlUtil {
}
/**
* Returns whether the {@value #EXTENSION_YUV_TARGET} extension is supported.
* Returns whether the {@link #EXTENSION_YUV_TARGET} extension is supported.
*
* <p>This extension allows sampling raw YUV values from an external texture, which is required
* for HDR.

View file

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

View file

@ -128,6 +128,4 @@ GL rendering mode has better performance, so should be preferred
* [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

View file

@ -115,12 +115,10 @@ then implement your own logic to use the renderer for a given track.
[top level README]: ../../README.md
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
[ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781
[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension
[Supported formats]: https://developer.android.com/guide/topics/media/exoplayer/supported-formats#ffmpeg-library
## Links
* [Troubleshooting using decoding extensions][]
<!-- 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

View file

@ -100,6 +100,4 @@ player, then implement your own logic to use the renderer for a given track.
* [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

View file

@ -104,6 +104,4 @@ player, then implement your own logic to use the renderer for a given track.
* [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

View file

@ -141,6 +141,4 @@ GL rendering mode has better performance, so should be preferred.
* [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

View file

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

View file

@ -2673,7 +2673,8 @@ import java.util.concurrent.TimeoutException;
"Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n"
+ "Expected thread: '%s'\n"
+ "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
+ "See https://developer.android.com/guide/topics/media/issues/"
+ "player-accessed-on-wrong-thread",
Thread.currentThread().getName(), getApplicationLooper().getThread().getName());
if (throwsWhenUsingWrongThread) {
throw new IllegalStateException(message);

View file

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

View file

@ -47,9 +47,12 @@ import java.lang.annotation.Target;
* valid state transitions are shown below, annotated with the methods that are called during each
* transition.
*
* <p style="align:center"><img src="doc-files/renderer-states.svg" alt="Renderer state
* transitions">
* <p style="align:center"><img
* 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
public interface Renderer extends PlayerMessage.Target {

View file

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

View file

@ -80,7 +80,7 @@ public class DefaultAudioTrackBufferSizeProvider
/**
* Sets the minimum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
* {@value #MIN_PCM_BUFFER_DURATION_US}.
* {@link #MIN_PCM_BUFFER_DURATION_US}.
*/
@CanIgnoreReturnValue
public Builder setMinPcmBufferDurationUs(int minPcmBufferDurationUs) {
@ -90,7 +90,7 @@ public class DefaultAudioTrackBufferSizeProvider
/**
* Sets the maximum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
* {@value #MAX_PCM_BUFFER_DURATION_US}.
* {@link #MAX_PCM_BUFFER_DURATION_US}.
*/
@CanIgnoreReturnValue
public Builder setMaxPcmBufferDurationUs(int maxPcmBufferDurationUs) {
@ -100,7 +100,7 @@ public class DefaultAudioTrackBufferSizeProvider
/**
* Sets the multiplication factor to apply to the minimum buffer size requested. Default is
* {@value #PCM_BUFFER_MULTIPLICATION_FACTOR}.
* {@link #PCM_BUFFER_MULTIPLICATION_FACTOR}.
*/
@CanIgnoreReturnValue
public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) {
@ -110,7 +110,7 @@ public class DefaultAudioTrackBufferSizeProvider
/**
* Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is
* {@value #PASSTHROUGH_BUFFER_DURATION_US}.
* {@link #PASSTHROUGH_BUFFER_DURATION_US}.
*/
@CanIgnoreReturnValue
public Builder setPassthroughBufferDurationUs(int passthroughBufferDurationUs) {
@ -119,7 +119,7 @@ public class DefaultAudioTrackBufferSizeProvider
}
/**
* The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@value
* The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@link
* #OFFLOAD_BUFFER_DURATION_US}.
*/
@CanIgnoreReturnValue
@ -130,7 +130,7 @@ public class DefaultAudioTrackBufferSizeProvider
/**
* Sets the multiplication factor to apply to the passthrough buffer for AC3 to avoid underruns
* on some devices (e.g., Broadcom 7271). Default is {@value #AC3_BUFFER_MULTIPLICATION_FACTOR}.
* on some devices (e.g., Broadcom 7271). Default is {@link #AC3_BUFFER_MULTIPLICATION_FACTOR}.
*/
@CanIgnoreReturnValue
public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) {

View file

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

View file

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

View file

@ -471,6 +471,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override
public final void prepare() {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
if (prepareCallsCount++ != 0) {
return;
}
@ -487,6 +488,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override
public final void release() {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
if (--prepareCallsCount != 0) {
return;
}
@ -513,6 +515,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override
public DrmSessionReference preacquireSession(
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
// Don't verify the playback thread, preacquireSession can be called from any thread.
checkState(prepareCallsCount > 0);
checkStateNotNull(playbackLooper);
PreacquiredSessionReference preacquiredSessionReference =
@ -525,6 +528,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Nullable
public DrmSession acquireSession(
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
checkState(prepareCallsCount > 0);
checkStateNotNull(playbackLooper);
return acquireSession(
@ -599,6 +603,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
@Override
public @C.CryptoType int getCryptoType(Format format) {
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
@C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType();
if (format.drmInitData == null) {
int trackType = MimeTypes.getTrackType(format.sampleMimeType);
@ -817,6 +822,23 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
}
}
private void verifyPlaybackThread(boolean allowBeforeSetPlayer) {
if (allowBeforeSetPlayer && playbackLooper == null) {
Log.w(
TAG,
"DefaultDrmSessionManager accessed before setPlayer(), possibly on the wrong thread.",
new IllegalStateException());
} else if (Thread.currentThread() != checkNotNull(playbackLooper).getThread()) {
Log.w(
TAG,
"DefaultDrmSessionManager accessed on the wrong thread.\nCurrent thread: "
+ Thread.currentThread().getName()
+ "\nExpected thread: "
+ playbackLooper.getThread().getName(),
new IllegalStateException());
}
}
/**
* Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}.
*

View file

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

View file

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

View file

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

View file

@ -214,6 +214,115 @@ public class MediaCodecRendererTest {
inOrder.verify(renderer).onProcessedOutputBuffer(600);
}
@Test
public void
render_withReplaceStreamAfterInitialEmptySampleStream_triggersOutputCallbacksInCorrectOrder()
throws Exception {
Format format1 =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build();
Format format2 =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build();
FakeSampleStream fakeSampleStream1 = createFakeSampleStream(format1 /* no samples */);
FakeSampleStream fakeSampleStream2 =
createFakeSampleStream(format2, /* sampleTimesUs...= */ 0, 100, 200);
MediaCodecRenderer renderer = spy(new TestRenderer());
renderer.init(/* index= */ 0, PlayerId.UNSET);
renderer.enable(
RendererConfiguration.DEFAULT,
new Format[] {format1},
fakeSampleStream1,
/* positionUs= */ 0,
/* joining= */ false,
/* mayRenderStartOfStream= */ true,
/* startPositionUs= */ 0,
/* offsetUs= */ 0);
renderer.start();
long positionUs = 0;
while (!renderer.hasReadStreamToEnd()) {
renderer.render(positionUs, SystemClock.elapsedRealtime());
positionUs += 100;
}
renderer.replaceStream(
new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 0, /* offsetUs= */ 0);
renderer.setCurrentStreamFinal();
while (!renderer.isEnded()) {
renderer.render(positionUs, SystemClock.elapsedRealtime());
positionUs += 100;
}
InOrder inOrder = inOrder(renderer);
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
inOrder.verify(renderer).onProcessedStreamChange();
inOrder.verify(renderer).onOutputFormatChanged(eq(format2), any());
inOrder.verify(renderer).onProcessedOutputBuffer(0);
inOrder.verify(renderer).onProcessedOutputBuffer(100);
inOrder.verify(renderer).onProcessedOutputBuffer(200);
}
@Test
public void
render_withReplaceStreamAfterIntermittentEmptySampleStream_triggersOutputCallbacksInCorrectOrder()
throws Exception {
Format format1 =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build();
Format format2 =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build();
Format format3 =
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(2000).build();
FakeSampleStream fakeSampleStream1 =
createFakeSampleStream(format1, /* sampleTimesUs...= */ 0, 100);
FakeSampleStream fakeSampleStream2 = createFakeSampleStream(format2 /* no samples */);
FakeSampleStream fakeSampleStream3 =
createFakeSampleStream(format3, /* sampleTimesUs...= */ 0, 100, 200);
MediaCodecRenderer renderer = spy(new TestRenderer());
renderer.init(/* index= */ 0, PlayerId.UNSET);
renderer.enable(
RendererConfiguration.DEFAULT,
new Format[] {format1},
fakeSampleStream1,
/* positionUs= */ 0,
/* joining= */ false,
/* mayRenderStartOfStream= */ true,
/* startPositionUs= */ 0,
/* offsetUs= */ 0);
renderer.start();
long positionUs = 0;
while (!renderer.hasReadStreamToEnd()) {
renderer.render(positionUs, SystemClock.elapsedRealtime());
positionUs += 100;
}
renderer.replaceStream(
new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 200, /* offsetUs= */ 200);
while (!renderer.hasReadStreamToEnd()) {
renderer.render(positionUs, SystemClock.elapsedRealtime());
positionUs += 100;
}
renderer.replaceStream(
new Format[] {format3}, fakeSampleStream3, /* startPositionUs= */ 200, /* offsetUs= */ 200);
renderer.setCurrentStreamFinal();
while (!renderer.isEnded()) {
renderer.render(positionUs, SystemClock.elapsedRealtime());
positionUs += 100;
}
InOrder inOrder = inOrder(renderer);
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
inOrder.verify(renderer).onOutputFormatChanged(eq(format1), any());
inOrder.verify(renderer).onProcessedOutputBuffer(0);
inOrder.verify(renderer).onProcessedOutputBuffer(100);
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200);
inOrder.verify(renderer).onProcessedStreamChange();
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200);
inOrder.verify(renderer).onProcessedStreamChange();
inOrder.verify(renderer).onOutputFormatChanged(eq(format3), any());
inOrder.verify(renderer).onProcessedOutputBuffer(200);
inOrder.verify(renderer).onProcessedOutputBuffer(300);
inOrder.verify(renderer).onProcessedOutputBuffer(400);
}
private FakeSampleStream createFakeSampleStream(Format format, long... sampleTimesUs) {
ImmutableList.Builder<FakeSampleStream.FakeSampleStreamItem> sampleListBuilder =
ImmutableList.builder();

View file

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

View file

@ -26,7 +26,7 @@ locally. Instructions for doing this can be found in the [top level README][].
## Using the module
To use the module, follow the instructions on the
[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support)
[Ad insertion page](https://developer.android.com/guide/topics/media/exoplayer/ad-insertion#declarative-ad-support)
of the developer guide. The `AdsLoaderProvider` passed to the player's
`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA
module only supports players that are accessed on the application's main thread.

View file

@ -273,7 +273,7 @@ public final class ImaAdsLoader implements AdsLoader {
/**
* Sets the duration in milliseconds for which the player must buffer while preloading an ad
* 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.
*
* <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that

View file

@ -49,6 +49,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo;
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException;
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspUdpUnsupportedTransportException;
import androidx.media3.exoplayer.rtsp.RtspMessageChannel.InterleavedBinaryDataListener;
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo;
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader;
@ -577,8 +578,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
receivedAuthorizationRequest = true;
return;
}
// fall through: if unauthorized and no userInfo present, or previous authentication
// unsuccessful.
// if unauthorized and no userInfo present, or previous authentication
// unsuccessful, then dispatch RtspPlaybackException
dispatchRtspError(
new RtspPlaybackException(
RtspMessageUtil.toMethodString(requestMethod) + " " + response.status));
return;
case 461:
String exceptionMessage =
RtspMessageUtil.toMethodString(requestMethod) + " " + response.status;
// If request was SETUP with UDP transport protocol, then throw
// RtspUdpUnsupportedTransportException.
String transportHeaderValue =
checkNotNull(matchingRequest.headers.get(RtspHeaders.TRANSPORT));
dispatchRtspError(
requestMethod == METHOD_SETUP && !transportHeaderValue.contains("TCP")
? new RtspUdpUnsupportedTransportException(exceptionMessage)
: new RtspPlaybackException(exceptionMessage));
return;
default:
dispatchRtspError(
new RtspPlaybackException(

View file

@ -518,7 +518,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
// using TCP. Retrying will setup new loadables, so will not retry with the current
// loadables.
retryWithRtpTcp();
isUsingRtpTcp = true;
}
return;
}
@ -644,7 +643,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Override
public void onPlaybackError(RtspPlaybackException error) {
playbackException = error;
if (error instanceof RtspMediaSource.RtspUdpUnsupportedTransportException && !isUsingRtpTcp) {
// Retry playback with TCP if we receive RtspUdpUnsupportedTransportException, and we are
// not already using TCP. Retrying will setup new loadables.
retryWithRtpTcp();
} else {
playbackException = error;
}
}
@Override
@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
}
private void retryWithRtpTcp() {
// Retry should only run once.
isUsingRtpTcp = true;
rtspClient.retryWithRtpTcp();
@Nullable

View file

@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource {
}
/** Thrown when an exception or error is encountered during loading an RTSP stream. */
public static final class RtspPlaybackException extends IOException {
public static class RtspPlaybackException extends IOException {
public RtspPlaybackException(String message) {
super(message);
}
@ -206,6 +206,13 @@ public final class RtspMediaSource extends BaseMediaSource {
}
}
/** Thrown when an RTSP Unsupported Transport error (461) is encountered during RTSP Setup. */
public static final class RtspUdpUnsupportedTransportException extends RtspPlaybackException {
public RtspUdpUnsupportedTransportException(String message) {
super(message);
}
}
private final MediaItem mediaItem;
private final RtpDataChannel.Factory rtpDataChannelFactory;
private final String userAgent;

View file

@ -453,4 +453,77 @@ public final class RtspClientTest {
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED);
}
@Test
public void connectServerAndClient_describeResponseRequiresAuthentication_doesNotUpdateTimeline()
throws Exception {
class ResponseProvider implements RtspServer.ResponseProvider {
@Override
public RtspResponse getOptionsResponse() {
return new RtspResponse(
/* status= */ 200,
new RtspHeaders.Builder().add(RtspHeaders.PUBLIC, "OPTIONS, DESCRIBE").build());
}
@Override
public RtspResponse getDescribeResponse(Uri requestedUri, RtspHeaders headers) {
String authorizationHeader = headers.get(RtspHeaders.AUTHORIZATION);
if (authorizationHeader == null) {
return new RtspResponse(
/* status= */ 401,
new RtspHeaders.Builder()
.add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ))
.add(
RtspHeaders.WWW_AUTHENTICATE,
"Digest realm=\"RTSP server\","
+ " nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\","
+ " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")
.add(RtspHeaders.WWW_AUTHENTICATE, "BASIC realm=\"WallyWorld\"")
.build());
}
if (!authorizationHeader.contains("Digest")) {
return new RtspResponse(
401,
new RtspHeaders.Builder()
.add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ))
.build());
}
return RtspTestUtils.newDescribeResponseWithSdpMessage(
"v=0\r\n"
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
+ "s=Exoplayer test\r\n"
+ "t=0 0\r\n"
// The session is 50.46s long.
+ "a=range:npt=0-50.46\r\n",
rtpPacketStreamDumps,
requestedUri);
}
}
rtspServer = new RtspServer(new ResponseProvider());
AtomicBoolean timelineRequestFailed = new AtomicBoolean();
rtspClient =
new RtspClient(
new SessionInfoListener() {
@Override
public void onSessionTimelineUpdated(
RtspSessionTiming timing, ImmutableList<RtspMediaTrack> tracks) {}
@Override
public void onSessionTimelineRequestFailed(
String message, @Nullable Throwable cause) {
timelineRequestFailed.set(true);
}
},
EMPTY_PLAYBACK_LISTENER,
/* userAgent= */ "ExoPlayer:RtspClientTest",
RtspTestUtils.getTestUri(rtspServer.startAndGetPortNumber()),
SocketFactory.getDefault(),
/* debugLoggingEnabled= */ false);
rtspClient.start();
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED);
}
}

View file

@ -15,6 +15,7 @@
*/
package androidx.media3.exoplayer.rtsp;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static com.google.common.truth.Truth.assertThat;
import static java.lang.Math.min;
@ -42,11 +43,13 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicReference;
import javax.net.SocketFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@ -58,30 +61,20 @@ import org.robolectric.annotation.Config;
@RunWith(AndroidJUnit4.class)
public final class RtspPlaybackTest {
private static final long DEFAULT_TIMEOUT_MS = 8000;
private static final String SESSION_DESCRIPTION =
"v=0\r\n"
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
+ "s=Exoplayer test\r\n"
+ "t=0 0\r\n";
private final Context applicationContext;
private final CapturingRenderersFactory capturingRenderersFactory;
private final Clock clock;
private final FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel;
private final RtpDataChannel.Factory rtpDataChannelFactory;
private Context applicationContext;
private CapturingRenderersFactory capturingRenderersFactory;
private Clock clock;
private RtpPacketStreamDump aacRtpPacketStreamDump;
// ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment.
private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump;
/** Creates a new instance. */
public RtspPlaybackTest() {
applicationContext = ApplicationProvider.getApplicationContext();
capturingRenderersFactory = new CapturingRenderersFactory(applicationContext);
clock = new FakeClock(/* isAutoAdvancing= */ true);
fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
}
private RtspServer rtspServer;
@Rule
public ShadowMediaCodecConfig mediaCodecConfig =
@ -89,61 +82,162 @@ public final class RtspPlaybackTest {
@Before
public void setUp() throws Exception {
applicationContext = ApplicationProvider.getApplicationContext();
capturingRenderersFactory = new CapturingRenderersFactory(applicationContext);
clock = new FakeClock(/* isAutoAdvancing= */ true);
aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json");
mpeg2tsRtpPacketStreamDump =
RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json");
}
@After
public void tearDown() {
Util.closeQuietly(rtspServer);
}
@Test
public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception {
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
ResponseProvider responseProvider =
new ResponseProvider(
clock,
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
fakeRtpDataChannel);
rtspServer = new RtspServer(responseProvider);
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
try (RtspServer rtspServer = new RtspServer(responseProvider)) {
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
player.prepare();
player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
player.release();
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
player.prepare();
player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
player.release();
// Only setup the supported track (aac).
assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump);
DumpFileAsserts.assertOutput(
applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
}
// Only setup the supported track (aac).
assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump);
DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
}
@Test
public void prepare_noSupportedTrack_throwsPreparationError() throws Exception {
try (RtspServer rtspServer =
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
rtspServer =
new RtspServer(
new ResponseProvider(
clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel))) {
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel));
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
AtomicReference<Throwable> playbackError = new AtomicReference<>();
player.prepare();
player.addListener(
new Listener() {
@Override
public void onPlayerError(PlaybackException error) {
playbackError.set(error);
}
});
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
player.release();
AtomicReference<Throwable> playbackError = new AtomicReference<>();
player.prepare();
player.addListener(
new Listener() {
@Override
public void onPlayerError(PlaybackException error) {
playbackError.set(error);
}
});
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
player.release();
assertThat(playbackError.get())
.hasCauseThat()
.hasMessageThat()
.contains("No playable track.");
}
assertThat(playbackError.get()).hasCauseThat().hasMessageThat().contains("No playable track.");
}
@Test
public void prepare_withUdpUnsupportedWithFallback_fallsbackToTcpAndPlaysUntilEnd()
throws Exception {
FakeTcpDataSourceRtpDataChannel fakeTcpRtpDataChannel = new FakeTcpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpTcpDataChannelFactory = (trackId) -> fakeTcpRtpDataChannel;
ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp =
new ResponseProviderSupportingOnlyTcp(
clock,
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
fakeTcpRtpDataChannel);
ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory =
new ForwardingRtpDataChannelFactory(
new UdpDataSourceRtpDataChannelFactory(DEFAULT_TIMEOUT_MS), rtpTcpDataChannelFactory);
rtspServer = new RtspServer(responseProviderSupportingOnlyTcp);
ExoPlayer player =
createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory);
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
player.prepare();
player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
player.release();
// Only setup the supported track (aac).
assertThat(responseProviderSupportingOnlyTcp.getDumpsForSetUpTracks())
.containsExactly(aacRtpPacketStreamDump);
DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
}
@Test
public void prepare_withUdpUnsupportedWithoutFallback_throwsRtspPlaybackException()
throws Exception {
FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel;
ResponseProviderSupportingOnlyTcp responseProvider =
new ResponseProviderSupportingOnlyTcp(
clock,
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
fakeUdpRtpDataChannel);
rtspServer = new RtspServer(responseProvider);
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
AtomicReference<PlaybackException> playbackError = new AtomicReference<>();
player.prepare();
player.addListener(
new Listener() {
@Override
public void onPlayerError(PlaybackException error) {
playbackError.set(error);
}
});
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
player.release();
assertThat(playbackError.get())
.hasCauseThat()
.isInstanceOf(RtspMediaSource.RtspPlaybackException.class);
assertThat(playbackError.get())
.hasCauseThat()
.hasMessageThat()
.contains("No fallback data channel factory for TCP retry");
}
@Test
public void prepare_withUdpUnsupportedWithUdpFallback_throwsRtspUdpUnsupportedTransportException()
throws Exception {
FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel;
ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp =
new ResponseProviderSupportingOnlyTcp(
clock,
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
fakeUdpRtpDataChannel);
ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory =
new ForwardingRtpDataChannelFactory(rtpDataChannelFactory, rtpDataChannelFactory);
rtspServer = new RtspServer(responseProviderSupportingOnlyTcp);
ExoPlayer player =
createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory);
AtomicReference<PlaybackException> playbackError = new AtomicReference<>();
player.prepare();
player.addListener(
new Listener() {
@Override
public void onPlayerError(PlaybackException error) {
playbackError.set(error);
}
});
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
player.release();
assertThat(playbackError.get())
.hasCauseThat()
.isInstanceOf(RtspMediaSource.RtspUdpUnsupportedTransportException.class);
assertThat(playbackError.get()).hasCauseThat().hasMessageThat().isEqualTo("SETUP 461");
}
private ExoPlayer createExoPlayer(
@ -163,16 +257,16 @@ public final class RtspPlaybackTest {
return player;
}
private static final class ResponseProvider implements RtspServer.ResponseProvider {
private static class ResponseProvider implements RtspServer.ResponseProvider {
private static final String SESSION_ID = "00000000";
protected static final String SESSION_ID = "00000000";
private final Clock clock;
private final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks;
private final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps;
protected final Clock clock;
protected final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks;
protected final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps;
private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener;
private RtpPacketTransmitter packetTransmitter;
protected RtpPacketTransmitter packetTransmitter;
/**
* Creates a new instance.
@ -240,22 +334,54 @@ public final class RtspPlaybackTest {
}
}
private static final class FakeUdpDataSourceRtpDataChannel extends BaseDataSource
implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener {
private static final class ResponseProviderSupportingOnlyTcp extends ResponseProvider {
private static final int LOCAL_PORT = 40000;
/**
* Creates a new instance.
*
* @param clock The {@link Clock} used in the test.
* @param rtpPacketStreamDumps A list of {@link RtpPacketStreamDump}.
* @param binaryDataListener A {@link RtspMessageChannel.InterleavedBinaryDataListener} to send
* RTP data.
*/
public ResponseProviderSupportingOnlyTcp(
Clock clock,
List<RtpPacketStreamDump> rtpPacketStreamDumps,
RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener) {
super(clock, rtpPacketStreamDumps, binaryDataListener);
}
@Override
public RtspResponse getSetupResponse(Uri requestedUri, RtspHeaders headers) {
String transportHeaderValue = checkNotNull(headers.get(RtspHeaders.TRANSPORT));
if (!transportHeaderValue.contains("TCP")) {
return new RtspResponse(
/* status= */ 461, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build());
}
for (RtpPacketStreamDump rtpPacketStreamDump : rtpPacketStreamDumps) {
if (requestedUri.toString().contains(rtpPacketStreamDump.trackName)) {
dumpsForSetUpTracks.add(rtpPacketStreamDump);
packetTransmitter = new RtpPacketTransmitter(rtpPacketStreamDump, clock);
}
}
return new RtspResponse(
/* status= */ 200, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build());
}
}
private abstract static class FakeBaseDataSourceRtpDataChannel extends BaseDataSource
implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener {
protected static final int LOCAL_PORT = 40000;
private final ConcurrentLinkedQueue<byte[]> packetQueue;
public FakeUdpDataSourceRtpDataChannel() {
public FakeBaseDataSourceRtpDataChannel() {
super(/* isNetwork= */ false);
packetQueue = new ConcurrentLinkedQueue<>();
}
@Override
public String getTransport() {
return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1);
}
public abstract String getTransport();
@Override
public int getLocalPort() {
@ -307,4 +433,49 @@ public final class RtspPlaybackTest {
return byteToRead;
}
}
private static final class FakeUdpDataSourceRtpDataChannel
extends FakeBaseDataSourceRtpDataChannel {
@Override
public String getTransport() {
return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1);
}
@Override
public RtspMessageChannel.InterleavedBinaryDataListener getInterleavedBinaryDataListener() {
return null;
}
}
private static final class FakeTcpDataSourceRtpDataChannel
extends FakeBaseDataSourceRtpDataChannel {
@Override
public String getTransport() {
return Util.formatInvariant(
"RTP/AVP/TCP;unicast;interleaved=%d-%d", LOCAL_PORT + 2, LOCAL_PORT + 3);
}
}
private static class ForwardingRtpDataChannelFactory implements RtpDataChannel.Factory {
private final RtpDataChannel.Factory rtpChannelFactory;
private final RtpDataChannel.Factory rtpFallbackChannelFactory;
public ForwardingRtpDataChannelFactory(
RtpDataChannel.Factory rtpChannelFactory,
RtpDataChannel.Factory rtpFallbackChannelFactory) {
this.rtpChannelFactory = rtpChannelFactory;
this.rtpFallbackChannelFactory = rtpFallbackChannelFactory;
}
@Override
public RtpDataChannel createAndOpenDataChannel(int trackId) throws IOException {
return rtpChannelFactory.createAndOpenDataChannel(trackId);
}
@Override
public RtpDataChannel.Factory createFallbackDataChannelFactory() {
return rtpFallbackChannelFactory;
}
}
}

View file

@ -16,6 +16,7 @@
package androidx.media3.extractor;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.CodecSpecificDataUtil;
@ -61,6 +62,9 @@ public final class HevcConfig {
int bufferPosition = 0;
int width = Format.NO_VALUE;
int height = Format.NO_VALUE;
@C.ColorSpace int colorSpace = Format.NO_VALUE;
@C.ColorRange int colorRange = Format.NO_VALUE;
@C.ColorTransfer int colorTransfer = Format.NO_VALUE;
float pixelWidthHeightRatio = 1;
@Nullable String codecs = null;
for (int i = 0; i < numberOfArrays; i++) {
@ -84,6 +88,9 @@ public final class HevcConfig {
buffer, bufferPosition, bufferPosition + nalUnitLength);
width = spsData.width;
height = spsData.height;
colorSpace = spsData.colorSpace;
colorRange = spsData.colorRange;
colorTransfer = spsData.colorTransfer;
pixelWidthHeightRatio = spsData.pixelWidthHeightRatio;
codecs =
CodecSpecificDataUtil.buildHevcCodecString(
@ -102,7 +109,15 @@ public final class HevcConfig {
List<byte[]> initializationData =
csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer);
return new HevcConfig(
initializationData, lengthSizeMinusOne + 1, width, height, pixelWidthHeightRatio, codecs);
initializationData,
lengthSizeMinusOne + 1,
width,
height,
pixelWidthHeightRatio,
codecs,
colorSpace,
colorRange,
colorTransfer);
} catch (ArrayIndexOutOfBoundsException e) {
throw ParserException.createForMalformedContainer("Error parsing HEVC config", e);
}
@ -129,6 +144,22 @@ public final class HevcConfig {
/** The pixel width to height ratio. */
public final float pixelWidthHeightRatio;
/**
* The {@link C.ColorSpace} of the video or {@link Format#NO_VALUE} if unknown or not applicable.
*/
public final @C.ColorSpace int colorSpace;
/**
* The {@link C.ColorRange} of the video or {@link Format#NO_VALUE} if unknown or not applicable.
*/
public final @C.ColorRange int colorRange;
/**
* The {@link C.ColorTransfer} of the video or {@link Format#NO_VALUE} if unknown or not
* applicable.
*/
public final @C.ColorTransfer int colorTransfer;
/**
* An RFC 6381 codecs string representing the video format, or {@code null} if not known.
*
@ -142,12 +173,18 @@ public final class HevcConfig {
int width,
int height,
float pixelWidthHeightRatio,
@Nullable String codecs) {
@Nullable String codecs,
@C.ColorSpace int colorSpace,
@C.ColorRange int colorRange,
@C.ColorTransfer int colorTransfer) {
this.initializationData = initializationData;
this.nalUnitLengthFieldLength = nalUnitLengthFieldLength;
this.width = width;
this.height = height;
this.pixelWidthHeightRatio = pixelWidthHeightRatio;
this.codecs = codecs;
this.colorSpace = colorSpace;
this.colorRange = colorRange;
this.colorTransfer = colorTransfer;
}
}

View file

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

View file

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

View file

@ -1164,6 +1164,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio;
}
codecs = hevcConfig.codecs;
colorSpace = hevcConfig.colorSpace;
colorRange = hevcConfig.colorRange;
colorTransfer = hevcConfig.colorTransfer;
} else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) {
@Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent);
if (dolbyVisionConfig != null) {
@ -1173,6 +1176,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
} else if (childAtomType == Atom.TYPE_vpcC) {
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9;
parent.setPosition(childStartPosition + Atom.FULL_HEADER_SIZE);
// See vpcC atom syntax: https://www.webmproject.org/vp9/mp4/#syntax_1
parent.skipBytes(2); // profile(8), level(8)
boolean fullRangeFlag = (parent.readUnsignedByte() & 1) != 0;
int colorPrimaries = parent.readUnsignedByte();
int transferCharacteristics = parent.readUnsignedByte();
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
colorTransfer =
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
} else if (childAtomType == Atom.TYPE_av1C) {
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
mimeType = MimeTypes.VIDEO_AV1;
@ -1252,26 +1265,33 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
}
}
} else if (childAtomType == Atom.TYPE_colr) {
int colorType = parent.readInt();
if (colorType == TYPE_nclx || colorType == TYPE_nclc) {
// For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html.
int colorPrimaries = parent.readUnsignedShort();
int transferCharacteristics = parent.readUnsignedShort();
parent.skipBytes(2); // matrix_coefficients.
// Only modify these values if they have not been previously established by the bitstream.
// If 'Atom.TYPE_hvcC' atom or 'Atom.TYPE_vpcC' is available, they will take precedence and
// overwrite any existing values.
if (colorSpace == Format.NO_VALUE
&& colorRange == Format.NO_VALUE
&& colorTransfer == Format.NO_VALUE) {
int colorType = parent.readInt();
if (colorType == TYPE_nclx || colorType == TYPE_nclc) {
// For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
// https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html.
int colorPrimaries = parent.readUnsignedShort();
int transferCharacteristics = parent.readUnsignedShort();
parent.skipBytes(2); // matrix_coefficients.
// Only try and read full_range_flag if the box is long enough. It should be present in
// all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some
// device cameras record videos with type=nclx without this final flag (and therefore
// size=18): https://github.com/google/ExoPlayer/issues/9332
boolean fullRangeFlag =
childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0;
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
colorTransfer =
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
} else {
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
// Only try and read full_range_flag if the box is long enough. It should be present in
// all colr boxes with type=nclx (Section 8.5.2.2 in ISO/IEC 14496-12:2012(E)) but some
// device cameras record videos with type=nclx without this final flag (and therefore
// size=18): https://github.com/google/ExoPlayer/issues/9332
boolean fullRangeFlag =
childAtomSize == 19 && (parent.readUnsignedByte() & 0b10000000) != 0;
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
colorTransfer =
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
} else {
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
}
}
}
childPosition += childAtomSize;
@ -1560,7 +1580,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
// because these streams can carry simultaneously multiple representations of the same
// audio. Use stereo by default.
channelCount = 2;
} else if (childAtomType == Atom.TYPE_ddts) {
} else if (childAtomType == Atom.TYPE_ddts || childAtomType == Atom.TYPE_udts) {
out.format =
new Format.Builder()
.setId(trackId)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,6 +35,10 @@ public final class MediaNotification {
/**
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
* 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
public interface ActionFactory {
@ -109,10 +113,20 @@ public final class MediaNotification {
*
* <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}.
*
* <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
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 {
/**
* Called when a {@link MediaNotification} is changed.

View file

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

View file

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

View file

@ -15,21 +15,17 @@
*/
package androidx.media3.session;
import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN;
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
@ -43,7 +39,6 @@ import android.os.Process;
import android.os.RemoteException;
import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent;
import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
@ -66,7 +61,6 @@ import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerCb;
import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
@ -74,9 +68,11 @@ import androidx.media3.session.SequencedFutureManager.SequencedFuture;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.initialization.qual.Initialized;
/* package */ class MediaSessionImpl {
@ -115,13 +111,13 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private final SessionToken sessionToken;
private final MediaSession instance;
@Nullable private final PendingIntent sessionActivity;
private final PendingIntent mediaButtonIntent;
@Nullable private final BroadcastReceiver broadcastReceiver;
private final Handler applicationHandler;
private final BitmapLoader bitmapLoader;
private final Runnable periodicSessionPositionInfoUpdateRunnable;
private final Handler mainHandler;
@Nullable private PlayerListener playerListener;
@Nullable private MediaSession.Listener mediaSessionListener;
private PlayerInfo playerInfo;
@ -156,6 +152,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity;
mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper());
this.callback = callback;
this.bitmapLoader = bitmapLoader;
@ -189,52 +186,21 @@ import org.checkerframework.checker.initialization.qual.Initialized;
sessionStub,
tokenExtras);
@Nullable ComponentName mbrComponent;
synchronized (STATIC_LOCK) {
if (!componentNamesInitialized) {
serviceComponentName =
MediaSessionImpl.serviceComponentName =
getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE);
if (serviceComponentName == null) {
serviceComponentName =
if (MediaSessionImpl.serviceComponentName == null) {
MediaSessionImpl.serviceComponentName =
getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE);
}
componentNamesInitialized = true;
}
mbrComponent = serviceComponentName;
}
int pendingIntentFlagMutable = Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0;
if (mbrComponent == null) {
// No service to revive playback after it's dead.
// Create a PendingIntent that points to the runtime broadcast receiver.
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
intent.setPackage(context.getPackageName());
mediaButtonIntent =
PendingIntent.getBroadcast(
context, /* requestCode= */ 0, intent, pendingIntentFlagMutable);
// Creates a fake ComponentName for MediaSessionCompat in pre-L.
mbrComponent = new ComponentName(context, context.getClass());
// Create and register a BroadcastReceiver for receiving PendingIntent.
broadcastReceiver = new MediaButtonReceiver();
IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON);
filter.addDataScheme(castNonNull(sessionUri.getScheme()));
Util.registerReceiverNotExported(context, broadcastReceiver, filter);
} else {
// Has MediaSessionService to revive playback after it's dead.
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
intent.setComponent(mbrComponent);
if (Util.SDK_INT >= 26) {
mediaButtonIntent =
PendingIntent.getForegroundService(context, 0, intent, pendingIntentFlagMutable);
} else {
mediaButtonIntent = PendingIntent.getService(context, 0, intent, pendingIntentFlagMutable);
}
broadcastReceiver = null;
}
sessionLegacyStub =
new MediaSessionLegacyStub(thisRef, mbrComponent, mediaButtonIntent, applicationHandler);
new MediaSessionLegacyStub(
/* session= */ thisRef, sessionUri, serviceComponentName, applicationHandler);
PlayerWrapper playerWrapper = new PlayerWrapper(player);
this.playerWrapper = playerWrapper;
@ -278,8 +244,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
playerInfo = newPlayerWrapper.createPlayerInfoForBundling();
onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
/* excludeTimeline= */ false, /* excludeTracks= */ false);
handleAvailablePlayerCommandsChanged(newPlayerWrapper.getAvailableCommands());
}
public void release() {
@ -305,10 +270,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
Log.w(TAG, "Exception thrown while closing", e);
}
sessionLegacyStub.release();
mediaButtonIntent.cancel();
if (broadcastReceiver != null) {
context.unregisterReceiver(broadcastReceiver);
}
sessionStub.release();
}
@ -395,7 +356,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private void dispatchOnPlayerInfoChanged(
PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) {
playerInfo = sessionStub.generateAndCacheUniqueTrackGroupIds(playerInfo);
List<ControllerInfo> controllers =
sessionStub.getConnectedControllersManager().getConnectedControllers();
for (int i = 0; i < controllers.size(); i++) {
@ -589,12 +550,25 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
/* package */ void onNotificationRefreshRequired() {
if (this.mediaSessionListener != null) {
this.mediaSessionListener.onNotificationRefreshRequired(instance);
}
postOrRun(
mainHandler,
() -> {
if (this.mediaSessionListener != null) {
this.mediaSessionListener.onNotificationRefreshRequired(instance);
}
});
}
/* package */ boolean onPlayRequested() {
if (Looper.myLooper() != Looper.getMainLooper()) {
SettableFuture<Boolean> playRequested = SettableFuture.create();
mainHandler.post(() -> playRequested.set(onPlayRequested()));
try {
return playRequested.get();
} catch (InterruptedException | ExecutionException e) {
throw new IllegalStateException(e);
}
}
if (this.mediaSessionListener != null) {
return this.mediaSessionListener.onPlayRequested(instance);
}
@ -772,6 +746,20 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
}
private void handleAvailablePlayerCommandsChanged(Player.Commands availableCommands) {
// Update PlayerInfo and do not force exclude elements in case they need to be updated because
// an available command has been removed.
onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
/* excludeTimeline= */ false, /* excludeTracks= */ false);
dispatchRemoteControllerTaskWithoutReturn(
(callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands));
// Forcefully update playback info to update VolumeProviderCompat in case
// COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed.
dispatchRemoteControllerTaskToLegacyStub(
(callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo));
}
/* @FunctionalInterface */
interface RemoteControllerTask {
@ -1182,16 +1170,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
if (player == null) {
return;
}
boolean excludeTracks = !availableCommands.contains(COMMAND_GET_TRACKS);
session.onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
/* excludeTimeline= */ false, excludeTracks);
session.dispatchRemoteControllerTaskWithoutReturn(
(callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands));
// Forcefully update playback info to update VolumeProviderCompat in case
// COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed.
session.dispatchRemoteControllerTaskToLegacyStub(
(callback, seq) -> callback.onDeviceInfoChanged(seq, session.playerInfo.deviceInfo));
session.handleAvailablePlayerCommandsChanged(availableCommands);
}
@Override
@ -1281,26 +1260,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
}
// TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver
private final class MediaButtonReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
return;
}
Uri sessionUri = intent.getData();
if (!Util.areEqual(sessionUri, MediaSessionImpl.this.sessionUri)) {
return;
}
KeyEvent keyEvent = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (keyEvent == null) {
return;
}
getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
}
}
private class PlayerInfoChangedHandler extends Handler {
private static final int MSG_PLAYER_INFO_CHANGED = 1;

View file

@ -35,6 +35,7 @@ import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.Player.STATE_IDLE;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM;
@ -43,9 +44,13 @@ import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
@ -107,6 +112,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private static final String TAG = "MediaSessionLegacyStub";
private static final int PENDING_INTENT_FLAG_MUTABLE =
Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0;
private static final String DEFAULT_MEDIA_SESSION_TAG_PREFIX = "androidx.media3.session.id";
private static final String DEFAULT_MEDIA_SESSION_TAG_DELIM = ".";
@ -122,6 +129,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSessionCompat sessionCompat;
private final String appPackageName;
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
private final boolean canResumePlaybackOnStart;
@Nullable private VolumeProviderCompat volumeProviderCompat;
private volatile long connectionTimeoutMs;
@ -130,8 +139,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
public MediaSessionLegacyStub(
MediaSessionImpl session,
ComponentName mbrComponent,
PendingIntent mediaButtonIntent,
Uri sessionUri,
@Nullable ComponentName serviceComponentName,
Handler handler) {
sessionImpl = session;
Context context = sessionImpl.getContext();
@ -145,6 +154,44 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
connectedControllersManager = new ConnectedControllersManager<>(session);
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
// Select a media button receiver component.
ComponentName receiverComponentName = queryPackageManagerForMediaButtonReceiver(context);
// Assume an app that intentionally puts a `MediaButtonReceiver` into the manifest has
// implemented some kind of resumption of the last recently played media item.
canResumePlaybackOnStart = receiverComponentName != null;
if (receiverComponentName == null) {
receiverComponentName = serviceComponentName;
}
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
PendingIntent mediaButtonIntent;
if (receiverComponentName == null) {
// Neither a media button receiver from the app manifest nor a service available that could
// handle media button events. Create a runtime receiver and a pending intent for it.
runtimeBroadcastReceiver = new MediaButtonReceiver();
IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON);
filter.addDataScheme(castNonNull(sessionUri.getScheme()));
Util.registerReceiverNotExported(context, runtimeBroadcastReceiver, filter);
// Create a pending intent to be broadcast to the receiver.
intent.setPackage(context.getPackageName());
mediaButtonIntent =
PendingIntent.getBroadcast(
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE);
// Creates a fake ComponentName for MediaSessionCompat in pre-L.
receiverComponentName = new ComponentName(context, context.getClass());
} else {
intent.setComponent(receiverComponentName);
mediaButtonIntent =
Objects.equals(serviceComponentName, receiverComponentName)
? (Util.SDK_INT >= 26
? PendingIntent.getForegroundService(
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE)
: PendingIntent.getService(
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE))
: PendingIntent.getBroadcast(
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE);
runtimeBroadcastReceiver = null;
}
String sessionCompatId =
TextUtils.join(
DEFAULT_MEDIA_SESSION_TAG_DELIM,
@ -153,7 +200,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
new MediaSessionCompat(
context,
sessionCompatId,
mbrComponent,
receiverComponentName,
mediaButtonIntent,
session.getToken().getExtras());
@ -168,12 +215,38 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionCompat.setCallback(thisRef, handler);
}
@Nullable
private static ComponentName queryPackageManagerForMediaButtonReceiver(Context context) {
PackageManager pm = context.getPackageManager();
Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
queryIntent.setPackage(context.getPackageName());
List<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. */
public void start() {
sessionCompat.setActive(true);
}
public void release() {
if (!canResumePlaybackOnStart) {
setMediaButtonReceiver(sessionCompat, /* mediaButtonReceiverIntent= */ null);
}
if (runtimeBroadcastReceiver != null) {
sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver);
}
sessionCompat.release();
}
@ -832,6 +905,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionCompat.setMetadata(metadataCompat);
}
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
private static void setMediaButtonReceiver(
MediaSessionCompat sessionCompat, @Nullable PendingIntent mediaButtonReceiverIntent) {
sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent);
}
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) {
sessionCompat.setQueue(queue);
@ -987,6 +1066,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
sessionImpl.getSessionCompat().setExtras(sessionExtras);
}
@Override
public void sendCustomCommand(int seq, SessionCommand command, Bundle args) {
sessionImpl.getSessionCompat().sendSessionEvent(command.customAction, args);
}
@Override
public void onPlayWhenReadyChanged(
int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason)
@ -1237,11 +1321,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
lastMediaMetadata = newMediaMetadata;
lastDurationMs = newDurationMs;
if (currentMediaItem == null) {
setMetadata(sessionCompat, /* metadataCompat= */ null);
return;
}
@Nullable Bitmap artworkBitmap = null;
ListenableFuture<Bitmap> bitmapFuture =
sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata);
@ -1353,4 +1432,24 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage();
}
// TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver
private final class MediaButtonReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (!Util.areEqual(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)) {
return;
}
Uri sessionUri = intent.getData();
if (!Util.areEqual(sessionUri, sessionUri)) {
return;
}
KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (keyEvent == null) {
return;
}
getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
}
}
}

View file

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

View file

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

View file

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

View file

@ -123,6 +123,10 @@ public final class SessionCommand implements Bundleable {
/**
* The extra bundle of a custom command. It will be {@link Bundle#EMPTY} for a predefined command.
*
* <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;
@ -143,7 +147,9 @@ public final class SessionCommand implements Bundleable {
* Creates a custom command.
*
* @param action The action of this custom command.
* @param extras An extra bundle for this custom command.
* @param extras An extra bundle for this custom command. This value is not used when the command
* is sent to a legacy {@link android.support.v4.media.session.MediaSessionCompat} or {@link
* android.support.v4.media.session.MediaControllerCompat}.
*/
public SessionCommand(String action, Bundle extras) {
commandCode = COMMAND_CODE_CUSTOM;

View file

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

View file

@ -0,0 +1,144 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.session;
import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
import static com.google.common.truth.Truth8.assertThat;
import static java.util.Arrays.stream;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.service.notification.StatusBarNotification;
import androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.test.utils.TestExoPlayerBuilder;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.android.controller.ServiceController;
import org.robolectric.shadows.ShadowLooper;
@RunWith(AndroidJUnit4.class)
public class MediaSessionServiceTest {
@Test
public void service_multipleSessionsOnMainThread_createsNotificationForEachSession() {
Context context = ApplicationProvider.getApplicationContext();
ExoPlayer player1 = new TestExoPlayerBuilder(context).build();
ExoPlayer player2 = new TestExoPlayerBuilder(context).build();
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get();
service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider(
service,
session -> 2000 + Integer.parseInt(session.getId()),
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
service.addSession(session1);
service.addSession(session2);
// Start the players so that we also create notifications for them.
player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
player1.prepare();
player1.play();
player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
player2.prepare();
player2.play();
ShadowLooper.idleMainLooper();
NotificationManager notificationService =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
assertThat(
stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId))
.containsExactly(2001, 2002);
serviceController.destroy();
session1.release();
session2.release();
player1.release();
player2.release();
}
@Test
public void service_multipleSessionsOnDifferentThreads_createsNotificationForEachSession()
throws Exception {
Context context = ApplicationProvider.getApplicationContext();
HandlerThread thread1 = new HandlerThread("player1");
HandlerThread thread2 = new HandlerThread("player2");
thread1.start();
thread2.start();
ExoPlayer player1 = new TestExoPlayerBuilder(context).setLooper(thread1.getLooper()).build();
ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build();
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get();
service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider(
service,
session -> 2000 + Integer.parseInt(session.getId()),
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
NotificationManager notificationService =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
service.addSession(session1);
service.addSession(session2);
// Start the players so that we also create notifications for them.
new Handler(thread1.getLooper())
.post(
() -> {
player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
player1.prepare();
player1.play();
});
new Handler(thread2.getLooper())
.post(
() -> {
player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
player2.prepare();
player2.play();
});
runMainLooperUntil(() -> notificationService.getActiveNotifications().length == 2);
assertThat(
stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId))
.containsExactly(2001, 2002);
serviceController.destroy();
session1.release();
session2.release();
new Handler(thread1.getLooper()).post(player1::release);
new Handler(thread2.getLooper()).post(player2::release);
thread1.quit();
thread2.quit();
}
private static final class TestService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) {
return null; // No need to support binding or pending intents for this test.
}
}
}

View file

@ -103,6 +103,7 @@ public class CommonConstants {
public static final String KEY_MAX_SEEK_TO_PREVIOUS_POSITION_MS = "maxSeekToPreviousPositionMs";
public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters";
public static final String KEY_CURRENT_TRACKS = "currentTracks";
public static final String KEY_AVAILABLE_COMMANDS = "availableCommands";
// SessionCompat arguments
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";

View file

@ -980,6 +980,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
assertThat(TestUtils.equals(receivedSessionExtras.get(1), sessionExtras)).isTrue();
}
@Test
public void broadcastCustomCommand_cnSessionEventCalled() throws Exception {
Bundle commandCallExtras = new Bundle();
commandCallExtras.putString("key-0", "value-0");
// Specify session command extras to see that they are NOT used.
Bundle sessionCommandExtras = new Bundle();
sessionCommandExtras.putString("key-0", "value-1");
SessionCommand sessionCommand = new SessionCommand("custom_action", sessionCommandExtras);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<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()
throws Exception {
@ -1056,8 +1085,9 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
}
@Test
public void onMediaMetadataChanged_updatesLegacyMetadata_correctModelConversion()
throws Exception {
public void
onMediaMetadataChanged_withGetMetadataAndGetCurrentMediaItemCommand_updatesLegacyMetadata()
throws Exception {
int testItemIndex = 3;
String testDisplayTitle = "displayTitle";
long testDurationMs = 30_000;
@ -1071,6 +1101,12 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
.setMediaId(testMediaItems.get(testItemIndex).mediaId)
.setMediaMetadata(testMediaMetadata)
.build());
session
.getMockPlayer()
.notifyAvailableCommandsChanged(
new Player.Commands.Builder()
.addAll(Player.COMMAND_GET_METADATA, Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
.build());
session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems));
session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex);
session.getMockPlayer().setDuration(testDurationMs);
@ -1102,6 +1138,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId);
}
@Test
public void onMediaMetadataChanged_withGetMetadataCommandOnly_updatesLegacyMetadata()
throws Exception {
int testItemIndex = 3;
String testDisplayTitle = "displayTitle";
List<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 {
AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>();

View file

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

View file

@ -42,6 +42,7 @@ import androidx.media3.common.IllegalSeekPositionException;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Metadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
@ -50,6 +51,7 @@ import androidx.media3.common.Rating;
import androidx.media3.common.StarRating;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
@ -427,7 +429,7 @@ public class MediaControllerTest {
assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs);
assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs);
assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters);
assertThat(currentTracksRef.get()).isEqualTo(currentTracks);
assertThat(currentTracksRef.get().getGroups()).hasSize(2);
assertTimelineMediaItemsEquals(timelineRef.get(), timeline);
assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex);
assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem);
@ -1211,6 +1213,118 @@ public class MediaControllerTest {
assertThat(mediaMetadata).isEqualTo(testMediaMetadata);
}
@Test
public void getCurrentTracks_hasEqualTrackGroupsForEqualGroupsInPlayer() throws Exception {
// Include metadata in Format to ensure the track group can't be fully bundled.
Tracks initialPlayerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1]),
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
Tracks updatedPlayerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
/* adaptiveSupported= */ true,
/* trackSupport= */ new int[] {C.FORMAT_HANDLED},
/* trackSelected= */ new boolean[] {true}),
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("3").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
Bundle playerConfig =
new RemoteMediaSession.MockPlayerConfigBuilder()
.setCurrentTracks(initialPlayerTracks)
.build();
remoteSession.setPlayer(playerConfig);
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
CountDownLatch trackChangedEvent = new CountDownLatch(1);
threadTestRule
.getHandler()
.postAndSync(
() ->
controller.addListener(
new Player.Listener() {
@Override
public void onTracksChanged(Tracks tracks) {
trackChangedEvent.countDown();
}
}));
Tracks initialControllerTracks =
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
// Do something unrelated first to ensure tracks are correctly kept even after multiple updates.
remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_READY);
remoteSession.getMockPlayer().notifyTracksChanged(updatedPlayerTracks);
trackChangedEvent.await();
Tracks updatedControllerTracks =
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
assertThat(initialControllerTracks.getGroups()).hasSize(2);
assertThat(updatedControllerTracks.getGroups()).hasSize(2);
assertThat(initialControllerTracks.getGroups().get(1).getMediaTrackGroup())
.isEqualTo(updatedControllerTracks.getGroups().get(0).getMediaTrackGroup());
}
@Test
public void getCurrentTracksAndTrackOverrides_haveEqualTrackGroupsForEqualGroupsInPlayer()
throws Exception {
// Include metadata in Format to ensure the track group can't be fully bundled.
TrackGroup playerTrackGroupForOverride =
new TrackGroup(new Format.Builder().setMetadata(new Metadata()).setId("2").build());
Tracks playerTracks =
new Tracks(
ImmutableList.of(
new Tracks.Group(
new TrackGroup(
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1]),
new Tracks.Group(
playerTrackGroupForOverride,
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[1],
/* trackSelected= */ new boolean[1])));
TrackSelectionParameters trackSelectionParameters =
TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
.buildUpon()
.addOverride(
new TrackSelectionOverride(playerTrackGroupForOverride, /* trackIndex= */ 0))
.build();
Bundle playerConfig =
new RemoteMediaSession.MockPlayerConfigBuilder()
.setCurrentTracks(playerTracks)
.setTrackSelectionParameters(trackSelectionParameters)
.build();
remoteSession.setPlayer(playerConfig);
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
Tracks controllerTracks = threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
TrackSelectionParameters controllerTrackSelectionParameters =
threadTestRule.getHandler().postAndSync(controller::getTrackSelectionParameters);
TrackGroup controllerTrackGroup = controllerTracks.getGroups().get(1).getMediaTrackGroup();
assertThat(controllerTrackSelectionParameters.overrides)
.containsExactly(
controllerTrackGroup,
new TrackSelectionOverride(controllerTrackGroup, /* trackIndex= */ 0));
}
@Test
public void
setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly()

View file

@ -78,6 +78,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.ext.truth.os.BundleSubject;
import androidx.test.filters.MediumTest;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Range;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
@ -414,6 +415,41 @@ public class MediaControllerWithMediaSessionCompatTest {
assertThat(timelineRef.get().getPeriodCount()).isEqualTo(0);
}
@Test
public void setQueue_withDuplicatedMediaItems_updatesAndNotifiesTimeline() throws Exception {
MediaController controller = controllerTestRule.createController(session.getSessionToken());
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<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 {
CountDownLatch latch = new CountDownLatch(1);

View file

@ -24,13 +24,21 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.Metadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.Util;
import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
@ -248,4 +256,58 @@ public class MediaSessionAndControllerTest {
player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
}
@Test
public void setTrackSelectionParameters_withOverrides_matchesExpectedTrackGroupInPlayer()
throws Exception {
MockPlayer player =
new MockPlayer.Builder().setApplicationLooper(Looper.getMainLooper()).build();
// Intentionally add metadata to the format as this can't be bundled.
Tracks.Group trackGroupInPlayer =
new Tracks.Group(
new TrackGroup(
new Format.Builder()
.setId("0")
.setSampleMimeType(MimeTypes.VIDEO_H264)
.setMetadata(new Metadata())
.build(),
new Format.Builder()
.setId("1")
.setSampleMimeType(MimeTypes.VIDEO_H264)
.setMetadata(new Metadata())
.build()),
/* adaptiveSupported= */ false,
/* trackSupport= */ new int[] {C.FORMAT_HANDLED, C.FORMAT_HANDLED},
/* trackSelected= */ new boolean[] {true, false});
player.currentTracks = new Tracks(ImmutableList.of(trackGroupInPlayer));
MediaSession session =
sessionTestRule.ensureReleaseAfterTest(
new MediaSession.Builder(context, player).setId(TAG).build());
MediaController controller = controllerTestRule.createController(session.getToken());
threadTestRule
.getHandler()
.postAndSync(
() ->
controller.setTrackSelectionParameters(
controller
.getTrackSelectionParameters()
.buildUpon()
.setOverrideForType(
new TrackSelectionOverride(
controller
.getCurrentTracks()
.getGroups()
.get(0)
.getMediaTrackGroup(),
/* trackIndex= */ 1))
.build()));
player.awaitMethodCalled(MockPlayer.METHOD_SET_TRACK_SELECTION_PARAMETERS, TIMEOUT_MS);
assertThat(player.trackSelectionParameters.overrides)
.containsExactly(
trackGroupInPlayer.getMediaTrackGroup(),
new TrackSelectionOverride(
trackGroupInPlayer.getMediaTrackGroup(), /* trackIndex= */ 1));
}
}

View file

@ -18,6 +18,7 @@ package androidx.media3.session;
import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS;
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE;
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
@ -397,6 +398,10 @@ public class MediaSessionProviderService extends Service {
player.trackSelectionParameters =
TrackSelectionParameters.fromBundle(trackSelectionParametersBundle);
}
@Nullable Bundle availableCommandsBundle = config.getBundle(KEY_AVAILABLE_COMMANDS);
if (availableCommandsBundle != null) {
player.commands = Player.Commands.CREATOR.fromBundle(availableCommandsBundle);
}
return player;
}

View file

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

View file

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

View file

@ -24,6 +24,7 @@ def addMissingAarTypeToXml(xml) {
"com.google.ads.interactivemedia.v3:interactivemedia",
"com.google.guava:guava",
"com.google.truth:truth",
"com.google.truth.extensions:truth-java8-extension",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:mockwebserver",
"org.mockito:mockito-core",
@ -77,6 +78,11 @@ def addMissingAarTypeToXml(xml) {
(isProjectLibrary
|| aar_dependencies.contains(dependencyName))
if (!hasJar && !hasAar) {
// To look for what kind of dependency it is i.e. aar or jar type,
// please expand the External Libraries in Project view in Android Studio
// and search for your dependency inside Gradle Script dependencies.
// .aar files have @aar suffix at the end of their name,
// while .jar files have nothing.
throw new IllegalStateException(
dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle")
}