mirror of
https://github.com/samsonjs/media.git
synced 2026-03-25 09:25:53 +00:00
commit
3c01488f8d
77 changed files with 1989 additions and 512 deletions
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/question.md
vendored
2
.github/ISSUE_TEMPLATE/question.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ android {
|
|||
versionCode project.ext.releaseVersionCode
|
||||
minSdkVersion project.ext.minSdkVersion
|
||||
targetSdkVersion project.ext.appTargetSdkVersion
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,8 @@ public interface AudioSink {
|
|||
+ audioTrackState
|
||||
+ " "
|
||||
+ ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")")
|
||||
+ " "
|
||||
+ format
|
||||
+ (isRecoverable ? " (recoverable)" : ""),
|
||||
audioTrackException);
|
||||
this.audioTrackState = audioTrackState;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<>();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue