mirror of
https://github.com/samsonjs/media.git
synced 2026-04-27 15:07:40 +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
|
label: Media3 Version
|
||||||
description: What version of Media3 (or ExoPlayer) are you using?
|
description: What version of Media3 (or ExoPlayer) are you using?
|
||||||
options:
|
options:
|
||||||
|
- Media3 1.0.1
|
||||||
- Media3 1.0.0
|
- Media3 1.0.0
|
||||||
- Media3 1.0.0-rc02
|
- Media3 1.0.0-rc02
|
||||||
- Media3 1.0.0-rc01
|
- Media3 1.0.0-rc01
|
||||||
|
|
@ -29,6 +30,7 @@ body:
|
||||||
- Media3 1.0.0-alpha03
|
- Media3 1.0.0-alpha03
|
||||||
- Media3 1.0.0-alpha02
|
- Media3 1.0.0-alpha02
|
||||||
- Media3 1.0.0-alpha01
|
- Media3 1.0.0-alpha01
|
||||||
|
- ExoPlayer 2.18.6
|
||||||
- ExoPlayer 2.18.5
|
- ExoPlayer 2.18.5
|
||||||
- ExoPlayer 2.18.4
|
- ExoPlayer 2.18.4
|
||||||
- ExoPlayer 2.18.3
|
- ExoPlayer 2.18.3
|
||||||
|
|
|
||||||
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
|
- Authentication HTTP headers
|
||||||
|
|
||||||
Don't forget to check ExoPlayer's supported formats and devices, if applicable
|
Don't forget to check ExoPlayer's supported formats and devices, if applicable
|
||||||
(https://exoplayer.dev/supported-formats.html).
|
(https://developer.android.com/guide/topics/media/exoplayer/supported-formats).
|
||||||
|
|
||||||
If there's something you don't want to post publicly, please submit the issue,
|
If there's something you don't want to post publicly, please submit the issue,
|
||||||
then email the link/bug report to dev.exoplayer@gmail.com using a subject in the
|
then email the link/bug report to dev.exoplayer@gmail.com using a subject in the
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,21 @@ We will also consider high quality pull requests. These should merge
|
||||||
into the `main` branch. Before a pull request can be accepted you must submit
|
into the `main` branch. Before a pull request can be accepted you must submit
|
||||||
a Contributor License Agreement, as described below.
|
a Contributor License Agreement, as described below.
|
||||||
|
|
||||||
|
### Code style
|
||||||
|
|
||||||
|
We follow the
|
||||||
|
[Google Java Style Guide](https://google.github.io/styleguide/javaguide.html)
|
||||||
|
and use [`google-java-format`](https://github.com/google/google-java-format) to
|
||||||
|
automatically reformat the code. Please consider auto-formatting your changes
|
||||||
|
before opening a PR (we will otherwise do this ourselves before merging). You
|
||||||
|
can use the various IDE integrations available, or bulk-reformat all the changes
|
||||||
|
you made on top of `main` using
|
||||||
|
[`google-java-format-diff.py`](https://github.com/google/google-java-format/blob/master/scripts/google-java-format-diff.py):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ git diff -U0 main... | google-java-format-diff.py -p1 -i
|
||||||
|
```
|
||||||
|
|
||||||
## Contributor license agreement
|
## Contributor license agreement
|
||||||
|
|
||||||
Contributions to any Google project must be accompanied by a Contributor
|
Contributions to any Google project must be accompanied by a Contributor
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,44 @@
|
||||||
# Release notes
|
# Release notes
|
||||||
|
|
||||||
|
### 1.0.1 (2023-04-18)
|
||||||
|
|
||||||
|
This release corresponds to the
|
||||||
|
[ExoPlayer 2.18.6 release](https://github.com/google/ExoPlayer/releases/tag/r2.18.6).
|
||||||
|
|
||||||
|
* Core library:
|
||||||
|
* Reset target live stream override when seeking to default position
|
||||||
|
([#11051](https://github.com/google/ExoPlayer/pull/11051)).
|
||||||
|
* Fix bug where empty sample streams in the media could cause playback to
|
||||||
|
be stuck.
|
||||||
|
* Session:
|
||||||
|
* Fix bug where multiple identical queue items published by a legacy
|
||||||
|
`MediaSessionCompat` result in an exception in `MediaController`
|
||||||
|
([#290](https://github.com/androidx/media/issues/290)).
|
||||||
|
* Add missing forwarding of `MediaSession.broadcastCustomCommand` to the
|
||||||
|
legacy `MediaControllerCompat.Callback.onSessionEvent`
|
||||||
|
([#293](https://github.com/androidx/media/issues/293)).
|
||||||
|
* Fix bug where calling `MediaSession.setPlayer` doesn't update the
|
||||||
|
available commands.
|
||||||
|
* Fix issue that `TrackSelectionOverride` instances sent from a
|
||||||
|
`MediaController` are ignored if they reference a group with
|
||||||
|
`Format.metadata`
|
||||||
|
([#296](https://github.com/androidx/media/issues/296)).
|
||||||
|
* Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be
|
||||||
|
available to access metadata via the legacy `MediaSessionCompat`.
|
||||||
|
* Fix issue where `MediaSession` instances on a background thread cause
|
||||||
|
crashes when used in `MediaSessionService`
|
||||||
|
([#318](https://github.com/androidx/media/issues/318)).
|
||||||
|
* Fix issue where a media button receiver was declared by the library
|
||||||
|
without the app having intended this
|
||||||
|
([#314](https://github.com/androidx/media/issues/314)).
|
||||||
|
* DASH:
|
||||||
|
* Fix handling of empty segment timelines
|
||||||
|
([#11014](https://github.com/google/ExoPlayer/issues/11014)).
|
||||||
|
* RTSP:
|
||||||
|
* Retry with TCP if RTSP Setup with UDP fails with RTSP Error 461
|
||||||
|
UnsupportedTransport
|
||||||
|
([#11069](https://github.com/google/ExoPlayer/issues/11069)).
|
||||||
|
|
||||||
### 1.0.0 (2023-03-22)
|
### 1.0.0 (2023-03-22)
|
||||||
|
|
||||||
This release corresponds to the
|
This release corresponds to the
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
project.ext {
|
project.ext {
|
||||||
releaseVersion = '1.0.0'
|
releaseVersion = '1.0.1'
|
||||||
releaseVersionCode = 1_000_000_3_00
|
releaseVersionCode = 1_000_001_3_00
|
||||||
minSdkVersion = 16
|
minSdkVersion = 16
|
||||||
appTargetSdkVersion = 33
|
appTargetSdkVersion = 33
|
||||||
// API version before restricting local file access.
|
// API version before restricting local file access.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ android {
|
||||||
versionCode project.ext.releaseVersionCode
|
versionCode project.ext.releaseVersionCode
|
||||||
minSdkVersion project.ext.minSdkVersion
|
minSdkVersion project.ext.minSdkVersion
|
||||||
targetSdkVersion project.ext.appTargetSdkVersion
|
targetSdkVersion project.ext.appTargetSdkVersion
|
||||||
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string>
|
<string name="unexpected_intent_action">Unexpected intent action: <xliff:g id="action">%1$s</xliff:g></string>
|
||||||
|
|
||||||
<string name="error_cleartext_not_permitted">Cleartext HTTP traffic not permitted. See https://exoplayer.dev/issues/cleartext-not-permitted</string>
|
<string name="error_cleartext_not_permitted">Cleartext HTTP traffic not permitted. See https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted</string>
|
||||||
|
|
||||||
<string name="error_generic">Playback failed</string>
|
<string name="error_generic">Playback failed</string>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|
@ -73,20 +74,24 @@ class MainActivity : AppCompatActivity() {
|
||||||
val intent = Intent(this, PlayerActivity::class.java)
|
val intent = Intent(this, PlayerActivity::class.java)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(
|
||||||
|
object : OnBackPressedCallback(/* enabled= */ true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
popPathStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == android.R.id.home) {
|
if (item.itemId == android.R.id.home) {
|
||||||
onBackPressed()
|
onBackPressedDispatcher.onBackPressed()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
|
||||||
popPathStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
initializeBrowser()
|
initializeBrowser()
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||||
updateMediaMetadataUI(controller.mediaMetadata)
|
updateMediaMetadataUI(controller.mediaMetadata)
|
||||||
updateShuffleSwitchUI(controller.shuffleModeEnabled)
|
updateShuffleSwitchUI(controller.shuffleModeEnabled)
|
||||||
updateRepeatSwitchUI(controller.repeatMode)
|
updateRepeatSwitchUI(controller.repeatMode)
|
||||||
|
playerView.setShowSubtitleButton(controller.currentTracks.isTypeSupported(TRACK_TYPE_TEXT))
|
||||||
|
|
||||||
controller.addListener(
|
controller.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,6 @@ manual steps.
|
||||||
(this will only appear if the AAR is present), then build and run the demo
|
(this will only appear if the AAR is present), then build and run the demo
|
||||||
app and select a MediaPipe-based effect.
|
app and select a MediaPipe-based effect.
|
||||||
|
|
||||||
[Transformer]: https://exoplayer.dev/transforming-media.html
|
[Transformer]: https://developer.android.com/guide/topics/media/transforming-media
|
||||||
[MediaPipe]: https://google.github.io/mediapipe/
|
[MediaPipe]: https://google.github.io/mediapipe/
|
||||||
[build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html
|
[build an AAR]: https://google.github.io/mediapipe/getting_started/android_archive_library.html
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import androidx.media3.effect.MatrixTransformation;
|
||||||
*/
|
*/
|
||||||
/* package */ final class MatrixTransformationFactory {
|
/* package */ final class MatrixTransformationFactory {
|
||||||
/**
|
/**
|
||||||
* Returns a {@link MatrixTransformation} that rescales the frames over the first {@value
|
* Returns a {@link MatrixTransformation} that rescales the frames over the first {@link
|
||||||
* #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases
|
* #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases
|
||||||
* linearly in size from a single point to filling the full output frame.
|
* linearly in size from a single point to filling the full output frame.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ public final class AuxEffectInfo {
|
||||||
* Creates an instance with the given effect identifier and send level.
|
* Creates an instance with the given effect identifier and send level.
|
||||||
*
|
*
|
||||||
* @param effectId The effect identifier. This is the value returned by {@link
|
* @param effectId The effect identifier. This is the value returned by {@link
|
||||||
* AudioEffect#getId()} on the effect, or {@value #NO_AUX_EFFECT_ID} which represents no
|
* AudioEffect#getId()} on the effect, or {@link #NO_AUX_EFFECT_ID} which represents no
|
||||||
* effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying
|
* effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying
|
||||||
* audio track.
|
* audio track.
|
||||||
* @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1
|
* @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1
|
||||||
* is full send. If {@code effectId} is not {@value #NO_AUX_EFFECT_ID}, this value is passed
|
* is full send. If {@code effectId} is not {@link #NO_AUX_EFFECT_ID}, this value is passed to
|
||||||
* to {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track.
|
* {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track.
|
||||||
*/
|
*/
|
||||||
public AuxEffectInfo(int effectId, float sendLevel) {
|
public AuxEffectInfo(int effectId, float sendLevel) {
|
||||||
this.effectId = effectId;
|
this.effectId = effectId;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ import java.util.UUID;
|
||||||
*
|
*
|
||||||
* <p>When building formats, populate all fields whose values are known and relevant to the type of
|
* <p>When building formats, populate all fields whose values are known and relevant to the type of
|
||||||
* format being constructed. For information about different types of format, see ExoPlayer's <a
|
* format being constructed. For information about different types of format, see ExoPlayer's <a
|
||||||
* href="https://exoplayer.dev/supported-formats.html">Supported formats page</a>.
|
* href="https://developer.android.com/guide/topics/media/exoplayer/supported-formats">Supported
|
||||||
|
* formats page</a>.
|
||||||
*
|
*
|
||||||
* <h2>Fields commonly relevant to all formats</h2>
|
* <h2>Fields commonly relevant to all formats</h2>
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import android.view.Surface;
|
||||||
import android.view.SurfaceHolder;
|
import android.view.SurfaceHolder;
|
||||||
import android.view.SurfaceView;
|
import android.view.SurfaceView;
|
||||||
import android.view.TextureView;
|
import android.view.TextureView;
|
||||||
|
import androidx.annotation.CallSuper;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.media3.common.text.Cue;
|
import androidx.media3.common.text.Cue;
|
||||||
import androidx.media3.common.text.CueGroup;
|
import androidx.media3.common.text.CueGroup;
|
||||||
|
|
@ -47,14 +48,29 @@ public class ForwardingPlayer implements Player {
|
||||||
return player.getApplicationLooper();
|
return player.getApplicationLooper();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calls {@link Player#addListener(Listener)} on the delegate. */
|
/**
|
||||||
|
* Calls {@link Player#addListener(Listener)} on the delegate.
|
||||||
|
*
|
||||||
|
* <p>Overrides of this method must delegate to {@code super.addListener} and not {@code
|
||||||
|
* delegate.addListener}, in order to ensure the correct {@link Player} instance is passed to
|
||||||
|
* {@link Player.Listener#onEvents(Player, Events)} (i.e. this forwarding instance, and not the
|
||||||
|
* underlying {@code delegate} instance).
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@CallSuper
|
||||||
public void addListener(Listener listener) {
|
public void addListener(Listener listener) {
|
||||||
player.addListener(new ForwardingListener(this, listener));
|
player.addListener(new ForwardingListener(this, listener));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calls {@link Player#removeListener(Listener)} on the delegate. */
|
/**
|
||||||
|
* Calls {@link Player#removeListener(Listener)} on the delegate.
|
||||||
|
*
|
||||||
|
* <p>Overrides of this method must delegate to {@code super.removeListener} and not {@code
|
||||||
|
* delegate.removeListener}, in order to ensure the listener 'matches' the listener added via
|
||||||
|
* {@link #addListener} (otherwise the listener registered on the delegate won't be removed).
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@CallSuper
|
||||||
public void removeListener(Listener listener) {
|
public void removeListener(Listener listener) {
|
||||||
player.removeListener(new ForwardingListener(this, listener));
|
player.removeListener(new ForwardingListener(this, listener));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ public final class MediaLibraryInfo {
|
||||||
|
|
||||||
/** The version of the library expressed as a string, for example "1.2.3" or "1.2.3-beta01". */
|
/** The version of the library expressed as a string, for example "1.2.3" or "1.2.3-beta01". */
|
||||||
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa.
|
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa.
|
||||||
public static final String VERSION = "1.0.0";
|
public static final String VERSION = "1.0.1";
|
||||||
|
|
||||||
/** The version of the library expressed as {@code TAG + "/" + VERSION}. */
|
/** The version of the library expressed as {@code TAG + "/" + VERSION}. */
|
||||||
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
|
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
|
||||||
public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.0";
|
public static final String VERSION_SLASHY = "AndroidXMedia3/1.0.1";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The version of the library expressed as an integer, for example 1002003300.
|
* The version of the library expressed as an integer, for example 1002003300.
|
||||||
|
|
@ -47,7 +47,7 @@ public final class MediaLibraryInfo {
|
||||||
* (123-045-006-3-00).
|
* (123-045-006-3-00).
|
||||||
*/
|
*/
|
||||||
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
|
// Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa.
|
||||||
public static final int VERSION_INT = 1_000_000_3_00;
|
public static final int VERSION_INT = 1_000_001_3_00;
|
||||||
|
|
||||||
/** Whether the library was compiled with {@link Assertions} checks enabled. */
|
/** Whether the library was compiled with {@link Assertions} checks enabled. */
|
||||||
public static final boolean ASSERTIONS_ENABLED = true;
|
public static final boolean ASSERTIONS_ENABLED = true;
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,9 @@ public class PlaybackException extends Exception implements Bundleable {
|
||||||
* Caused by the player trying to access cleartext HTTP traffic (meaning http:// rather than
|
* Caused by the player trying to access cleartext HTTP traffic (meaning http:// rather than
|
||||||
* https://) when the app's Network Security Configuration does not permit it.
|
* https://) when the app's Network Security Configuration does not permit it.
|
||||||
*
|
*
|
||||||
* <p>See <a href="https://exoplayer.dev/issues/cleartext-not-permitted">this corresponding
|
* <p>See <a
|
||||||
* troubleshooting topic</a>.
|
* href="https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted">this
|
||||||
|
* corresponding troubleshooting topic</a>.
|
||||||
*/
|
*/
|
||||||
public static final int ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED = 2007;
|
public static final int ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED = 2007;
|
||||||
/** Caused by reading data out of the data bound. */
|
/** Caused by reading data out of the data bound. */
|
||||||
|
|
|
||||||
|
|
@ -3355,7 +3355,8 @@ public abstract class SimpleBasePlayer extends BasePlayer {
|
||||||
"Player is accessed on the wrong thread.\n"
|
"Player is accessed on the wrong thread.\n"
|
||||||
+ "Current thread: '%s'\n"
|
+ "Current thread: '%s'\n"
|
||||||
+ "Expected thread: '%s'\n"
|
+ "Expected thread: '%s'\n"
|
||||||
+ "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
|
+ "See https://developer.android.com/guide/topics/media/issues/"
|
||||||
|
+ "player-accessed-on-wrong-thread",
|
||||||
Thread.currentThread().getName(), applicationLooper.getThread().getName());
|
Thread.currentThread().getName(), applicationLooper.getThread().getName());
|
||||||
throw new IllegalStateException(message);
|
throw new IllegalStateException(message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2 id="single-file">Single media file or on-demand stream</h2>
|
* <h2 id="single-file">Single media file or on-demand stream</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-single-file.svg" alt="Example timeline for a
|
* <p style="align:center"><img
|
||||||
* single file">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-single-file.svg"
|
||||||
|
* alt="Example timeline for a single file">
|
||||||
*
|
*
|
||||||
* <p>A timeline for a single media file or on-demand stream consists of a single period and window.
|
* <p>A timeline for a single media file or on-demand stream consists of a single period and window.
|
||||||
* The window spans the whole period, indicating that all parts of the media are available for
|
* The window spans the whole period, indicating that all parts of the media are available for
|
||||||
|
|
@ -71,8 +72,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2>Playlist of media files or on-demand streams</h2>
|
* <h2>Playlist of media files or on-demand streams</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-playlist.svg" alt="Example timeline for a
|
* <p style="align:center"><img
|
||||||
* playlist of files">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-playlist.svg"
|
||||||
|
* alt="Example timeline for a playlist of files">
|
||||||
*
|
*
|
||||||
* <p>A timeline for a playlist of media files or on-demand streams consists of multiple periods,
|
* <p>A timeline for a playlist of media files or on-demand streams consists of multiple periods,
|
||||||
* each with its own window. Each window spans the whole of the corresponding period, and typically
|
* each with its own window. Each window spans the whole of the corresponding period, and typically
|
||||||
|
|
@ -82,8 +84,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2 id="live-limited">Live stream with limited availability</h2>
|
* <h2 id="live-limited">Live stream with limited availability</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-live-limited.svg" alt="Example timeline for
|
* <p style="align:center"><img
|
||||||
* a live stream with limited availability">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-limited.svg"
|
||||||
|
* alt="Example timeline for a live stream with limited availability">
|
||||||
*
|
*
|
||||||
* <p>A timeline for a live stream consists of a period whose duration is unknown, since it's
|
* <p>A timeline for a live stream consists of a period whose duration is unknown, since it's
|
||||||
* continually extending as more content is broadcast. If content only remains available for a
|
* continually extending as more content is broadcast. If content only remains available for a
|
||||||
|
|
@ -95,8 +98,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2>Live stream with indefinite availability</h2>
|
* <h2>Live stream with indefinite availability</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-live-indefinite.svg" alt="Example timeline
|
* <p style="align:center"><img
|
||||||
* for a live stream with indefinite availability">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-indefinite.svg"
|
||||||
|
* alt="Example timeline for a live stream with indefinite availability">
|
||||||
*
|
*
|
||||||
* <p>A timeline for a live stream with indefinite availability is similar to the <a
|
* <p>A timeline for a live stream with indefinite availability is similar to the <a
|
||||||
* href="#live-limited">Live stream with limited availability</a> case, except that the window
|
* href="#live-limited">Live stream with limited availability</a> case, except that the window
|
||||||
|
|
@ -105,8 +109,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2 id="live-multi-period">Live stream with multiple periods</h2>
|
* <h2 id="live-multi-period">Live stream with multiple periods</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-live-multi-period.svg" alt="Example timeline
|
* <p style="align:center"><img
|
||||||
* for a live stream with multiple periods">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-live-multi-period.svg"
|
||||||
|
* alt="Example timeline for a live stream with multiple periods">
|
||||||
*
|
*
|
||||||
* <p>This case arises when a live stream is explicitly divided into separate periods, for example
|
* <p>This case arises when a live stream is explicitly divided into separate periods, for example
|
||||||
* at content boundaries. This case is similar to the <a href="#live-limited">Live stream with
|
* at content boundaries. This case is similar to the <a href="#live-limited">Live stream with
|
||||||
|
|
@ -115,8 +120,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2>On-demand stream followed by live stream</h2>
|
* <h2>On-demand stream followed by live stream</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-advanced.svg" alt="Example timeline for an
|
* <p style="align:center"><img
|
||||||
* on-demand stream followed by a live stream">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-advanced.svg"
|
||||||
|
* alt="Example timeline for an on-demand stream followed by a live stream">
|
||||||
*
|
*
|
||||||
* <p>This case is the concatenation of the <a href="#single-file">Single media file or on-demand
|
* <p>This case is the concatenation of the <a href="#single-file">Single media file or on-demand
|
||||||
* stream</a> and <a href="#multi-period">Live stream with multiple periods</a> cases. When playback
|
* stream</a> and <a href="#multi-period">Live stream with multiple periods</a> cases. When playback
|
||||||
|
|
@ -125,12 +131,15 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <h2 id="single-file-midrolls">On-demand stream with mid-roll ads</h2>
|
* <h2 id="single-file-midrolls">On-demand stream with mid-roll ads</h2>
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/timeline-single-file-midrolls.svg" alt="Example
|
* <p style="align:center"><img
|
||||||
* timeline for an on-demand stream with mid-roll ad groups">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/timeline-single-file-midrolls.svg"
|
||||||
|
* alt="Example timeline for an on-demand stream with mid-roll ad groups">
|
||||||
*
|
*
|
||||||
* <p>This case includes mid-roll ad groups, which are defined as part of the timeline's single
|
* <p>This case includes mid-roll ad groups, which are defined as part of the timeline's single
|
||||||
* period. The period can be queried for information about the ad groups and the ads they contain.
|
* period. The period can be queried for information about the ad groups and the ads they contain.
|
||||||
*/
|
*/
|
||||||
|
// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on
|
||||||
|
// developer.android.com.
|
||||||
public abstract class Timeline implements Bundleable {
|
public abstract class Timeline implements Bundleable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,18 @@ public final class Tracks implements Bundleable {
|
||||||
return mediaTrackGroup.type;
|
return mediaTrackGroup.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the {@code Group} with a new {@link TrackGroup#id}.
|
||||||
|
*
|
||||||
|
* @param groupId The new {@link TrackGroup#id}
|
||||||
|
* @return The copied {@code Group}.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
public Group copyWithId(String groupId) {
|
||||||
|
return new Group(
|
||||||
|
mediaTrackGroup.copyWithId(groupId), adaptiveSupported, trackSupport, trackSelected);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(@Nullable Object other) {
|
public boolean equals(@Nullable Object other) {
|
||||||
if (this == other) {
|
if (this == other) {
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ public final class GlUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether creating a GL context with {@value #EXTENSION_PROTECTED_CONTENT} is possible.
|
* Returns whether creating a GL context with {@link #EXTENSION_PROTECTED_CONTENT} is possible.
|
||||||
*
|
*
|
||||||
* <p>If {@code true}, the device supports a protected output path for DRM content when using GL.
|
* <p>If {@code true}, the device supports a protected output path for DRM content when using GL.
|
||||||
*/
|
*/
|
||||||
|
|
@ -171,7 +171,7 @@ public final class GlUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the {@value #EXTENSION_SURFACELESS_CONTEXT} extension is supported.
|
* Returns whether the {@link #EXTENSION_SURFACELESS_CONTEXT} extension is supported.
|
||||||
*
|
*
|
||||||
* <p>This extension allows passing {@link EGL14#EGL_NO_SURFACE} for both the write and read
|
* <p>This extension allows passing {@link EGL14#EGL_NO_SURFACE} for both the write and read
|
||||||
* surfaces in a call to {@link EGL14#eglMakeCurrent(EGLDisplay, EGLSurface, EGLSurface,
|
* surfaces in a call to {@link EGL14#eglMakeCurrent(EGLDisplay, EGLSurface, EGLSurface,
|
||||||
|
|
@ -187,7 +187,7 @@ public final class GlUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the {@value #EXTENSION_YUV_TARGET} extension is supported.
|
* Returns whether the {@link #EXTENSION_YUV_TARGET} extension is supported.
|
||||||
*
|
*
|
||||||
* <p>This extension allows sampling raw YUV values from an external texture, which is required
|
* <p>This extension allows sampling raw YUV values from an external texture, which is required
|
||||||
* for HDR.
|
* for HDR.
|
||||||
|
|
|
||||||
|
|
@ -375,8 +375,8 @@ public interface HttpDataSource extends DataSource {
|
||||||
/**
|
/**
|
||||||
* Thrown when cleartext HTTP traffic is not permitted. For more information including how to
|
* Thrown when cleartext HTTP traffic is not permitted. For more information including how to
|
||||||
* enable cleartext traffic, see the <a
|
* enable cleartext traffic, see the <a
|
||||||
* href="https://exoplayer.dev/issues/cleartext-not-permitted">corresponding troubleshooting
|
* href="https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted">corresponding
|
||||||
* topic</a>.
|
* troubleshooting topic</a>.
|
||||||
*/
|
*/
|
||||||
final class CleartextNotPermittedException extends HttpDataSourceException {
|
final class CleartextNotPermittedException extends HttpDataSourceException {
|
||||||
|
|
||||||
|
|
@ -384,7 +384,7 @@ public interface HttpDataSource extends DataSource {
|
||||||
public CleartextNotPermittedException(IOException cause, DataSpec dataSpec) {
|
public CleartextNotPermittedException(IOException cause, DataSpec dataSpec) {
|
||||||
super(
|
super(
|
||||||
"Cleartext HTTP traffic not permitted. See"
|
"Cleartext HTTP traffic not permitted. See"
|
||||||
+ " https://exoplayer.dev/issues/cleartext-not-permitted",
|
+ " https://developer.android.com/guide/topics/media/issues/cleartext-not-permitted",
|
||||||
cause,
|
cause,
|
||||||
dataSpec,
|
dataSpec,
|
||||||
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,4 @@ GL rendering mode has better performance, so should be preferred
|
||||||
|
|
||||||
* [Troubleshooting using decoding extensions][]
|
* [Troubleshooting using decoding extensions][]
|
||||||
|
|
||||||
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
|
|
||||||
|
|
||||||
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playbacks
|
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playbacks
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,10 @@ then implement your own logic to use the renderer for a given track.
|
||||||
[top level README]: ../../README.md
|
[top level README]: ../../README.md
|
||||||
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
|
[Android NDK]: https://developer.android.com/tools/sdk/ndk/index.html
|
||||||
[ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781
|
[ExoPlayer issue 2781]: https://github.com/google/ExoPlayer/issues/2781
|
||||||
[Supported formats]: https://exoplayer.dev/supported-formats.html#ffmpeg-extension
|
[Supported formats]: https://developer.android.com/guide/topics/media/exoplayer/supported-formats#ffmpeg-library
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
* [Troubleshooting using decoding extensions][]
|
* [Troubleshooting using decoding extensions][]
|
||||||
|
|
||||||
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
|
|
||||||
|
|
||||||
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,4 @@ player, then implement your own logic to use the renderer for a given track.
|
||||||
|
|
||||||
* [Troubleshooting using decoding extensions][]
|
* [Troubleshooting using decoding extensions][]
|
||||||
|
|
||||||
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
|
|
||||||
|
|
||||||
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,4 @@ player, then implement your own logic to use the renderer for a given track.
|
||||||
|
|
||||||
* [Troubleshooting using decoding extensions][]
|
* [Troubleshooting using decoding extensions][]
|
||||||
|
|
||||||
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
|
|
||||||
|
|
||||||
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,4 @@ GL rendering mode has better performance, so should be preferred.
|
||||||
|
|
||||||
* [Troubleshooting using decoding extensions][]
|
* [Troubleshooting using decoding extensions][]
|
||||||
|
|
||||||
<!-- TODO(b/276289331): Add Javadoc link when it's published on developer.android.com -->
|
|
||||||
|
|
||||||
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
[Troubleshooting using decoding extensions]: https://developer.android.com/guide/topics/media/exoplayer/troubleshooting#how-can-i-get-a-decoding-library-to-load-and-be-used-for-playback
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,9 @@ import java.util.List;
|
||||||
*
|
*
|
||||||
* <p>The figure below shows ExoPlayer's threading model.
|
* <p>The figure below shows ExoPlayer's threading model.
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/exoplayer-threading-model.svg" alt="ExoPlayer's
|
* <p style="align:center"><img
|
||||||
* threading model">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/exoplayer-threading-model.svg"
|
||||||
|
* alt="ExoPlayer's threading model">
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>ExoPlayer instances must be accessed from a single application thread unless indicated
|
* <li>ExoPlayer instances must be accessed from a single application thread unless indicated
|
||||||
|
|
@ -158,6 +159,8 @@ import java.util.List;
|
||||||
* may use background threads to load data. These are implementation specific.
|
* may use background threads to load data. These are implementation specific.
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
|
// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on
|
||||||
|
// developer.android.com.
|
||||||
public interface ExoPlayer extends Player {
|
public interface ExoPlayer extends Player {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2673,7 +2673,8 @@ import java.util.concurrent.TimeoutException;
|
||||||
"Player is accessed on the wrong thread.\n"
|
"Player is accessed on the wrong thread.\n"
|
||||||
+ "Current thread: '%s'\n"
|
+ "Current thread: '%s'\n"
|
||||||
+ "Expected thread: '%s'\n"
|
+ "Expected thread: '%s'\n"
|
||||||
+ "See https://exoplayer.dev/issues/player-accessed-on-wrong-thread",
|
+ "See https://developer.android.com/guide/topics/media/issues/"
|
||||||
|
+ "player-accessed-on-wrong-thread",
|
||||||
Thread.currentThread().getName(), getApplicationLooper().getThread().getName());
|
Thread.currentThread().getName(), getApplicationLooper().getThread().getName());
|
||||||
if (throwsWhenUsingWrongThread) {
|
if (throwsWhenUsingWrongThread) {
|
||||||
throw new IllegalStateException(message);
|
throw new IllegalStateException(message);
|
||||||
|
|
|
||||||
|
|
@ -1239,7 +1239,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
/* newPeriodId= */ periodId,
|
/* newPeriodId= */ periodId,
|
||||||
/* oldTimeline= */ playbackInfo.timeline,
|
/* oldTimeline= */ playbackInfo.timeline,
|
||||||
/* oldPeriodId= */ playbackInfo.periodId,
|
/* oldPeriodId= */ playbackInfo.periodId,
|
||||||
/* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs);
|
/* positionForTargetOffsetOverrideUs= */ requestedContentPositionUs,
|
||||||
|
/* forceSetTargetOffsetOverride= */ true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
playbackInfo =
|
playbackInfo =
|
||||||
|
|
@ -1882,7 +1883,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
/* oldPeriodId= */ playbackInfo.periodId,
|
/* oldPeriodId= */ playbackInfo.periodId,
|
||||||
/* positionForTargetOffsetOverrideUs */ positionUpdate.setTargetLiveOffset
|
/* positionForTargetOffsetOverrideUs */ positionUpdate.setTargetLiveOffset
|
||||||
? newPositionUs
|
? newPositionUs
|
||||||
: C.TIME_UNSET);
|
: C.TIME_UNSET,
|
||||||
|
/* forceSetTargetOffsetOverride= */ false);
|
||||||
if (periodPositionChanged
|
if (periodPositionChanged
|
||||||
|| newRequestedContentPositionUs != playbackInfo.requestedContentPositionUs) {
|
|| newRequestedContentPositionUs != playbackInfo.requestedContentPositionUs) {
|
||||||
Object oldPeriodUid = playbackInfo.periodId.periodUid;
|
Object oldPeriodUid = playbackInfo.periodId.periodUid;
|
||||||
|
|
@ -1920,7 +1922,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
MediaPeriodId newPeriodId,
|
MediaPeriodId newPeriodId,
|
||||||
Timeline oldTimeline,
|
Timeline oldTimeline,
|
||||||
MediaPeriodId oldPeriodId,
|
MediaPeriodId oldPeriodId,
|
||||||
long positionForTargetOffsetOverrideUs)
|
long positionForTargetOffsetOverrideUs,
|
||||||
|
boolean forceSetTargetOffsetOverride)
|
||||||
throws ExoPlaybackException {
|
throws ExoPlaybackException {
|
||||||
if (!shouldUseLivePlaybackSpeedControl(newTimeline, newPeriodId)) {
|
if (!shouldUseLivePlaybackSpeedControl(newTimeline, newPeriodId)) {
|
||||||
// Live playback speed control is unused for the current period, reset speed to user-defined
|
// Live playback speed control is unused for the current period, reset speed to user-defined
|
||||||
|
|
@ -1950,8 +1953,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
int oldWindowIndex = oldTimeline.getPeriodByUid(oldPeriodId.periodUid, period).windowIndex;
|
int oldWindowIndex = oldTimeline.getPeriodByUid(oldPeriodId.periodUid, period).windowIndex;
|
||||||
oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid;
|
oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid;
|
||||||
}
|
}
|
||||||
if (!Util.areEqual(oldWindowUid, windowUid)) {
|
if (!Util.areEqual(oldWindowUid, windowUid) || forceSetTargetOffsetOverride) {
|
||||||
// Reset overridden target live offset to media values if window changes.
|
// Reset overridden target live offset to media values if window changes or if seekTo
|
||||||
|
// default live position.
|
||||||
livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET);
|
livePlaybackSpeedControl.setTargetLiveOffsetOverrideUs(C.TIME_UNSET);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2074,7 +2078,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
/* newPeriodId= */ readingPeriodHolder.info.id,
|
/* newPeriodId= */ readingPeriodHolder.info.id,
|
||||||
/* oldTimeline= */ playbackInfo.timeline,
|
/* oldTimeline= */ playbackInfo.timeline,
|
||||||
/* oldPeriodId= */ oldReadingPeriodHolder.info.id,
|
/* oldPeriodId= */ oldReadingPeriodHolder.info.id,
|
||||||
/* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET);
|
/* positionForTargetOffsetOverrideUs= */ C.TIME_UNSET,
|
||||||
|
/* forceSetTargetOffsetOverride= */ false);
|
||||||
|
|
||||||
if (readingPeriodHolder.prepared
|
if (readingPeriodHolder.prepared
|
||||||
&& readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) {
|
&& readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) {
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,12 @@ import java.lang.annotation.Target;
|
||||||
* valid state transitions are shown below, annotated with the methods that are called during each
|
* valid state transitions are shown below, annotated with the methods that are called during each
|
||||||
* transition.
|
* transition.
|
||||||
*
|
*
|
||||||
* <p style="align:center"><img src="doc-files/renderer-states.svg" alt="Renderer state
|
* <p style="align:center"><img
|
||||||
* transitions">
|
* src="https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/doc-files/renderer-states.svg"
|
||||||
|
* alt="Renderer state transitions">
|
||||||
*/
|
*/
|
||||||
|
// TODO(b/276289331): Revert to media3-hosted SVG links above once they're available on
|
||||||
|
// developer.android.com.
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public interface Renderer extends PlayerMessage.Target {
|
public interface Renderer extends PlayerMessage.Target {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,8 @@ public interface AudioSink {
|
||||||
+ audioTrackState
|
+ audioTrackState
|
||||||
+ " "
|
+ " "
|
||||||
+ ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")")
|
+ ("Config(" + sampleRate + ", " + channelConfig + ", " + bufferSize + ")")
|
||||||
|
+ " "
|
||||||
|
+ format
|
||||||
+ (isRecoverable ? " (recoverable)" : ""),
|
+ (isRecoverable ? " (recoverable)" : ""),
|
||||||
audioTrackException);
|
audioTrackException);
|
||||||
this.audioTrackState = audioTrackState;
|
this.audioTrackState = audioTrackState;
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the minimum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
|
* Sets the minimum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
|
||||||
* {@value #MIN_PCM_BUFFER_DURATION_US}.
|
* {@link #MIN_PCM_BUFFER_DURATION_US}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setMinPcmBufferDurationUs(int minPcmBufferDurationUs) {
|
public Builder setMinPcmBufferDurationUs(int minPcmBufferDurationUs) {
|
||||||
|
|
@ -90,7 +90,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the maximum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
|
* Sets the maximum length for PCM {@link AudioTrack} buffers, in microseconds. Default is
|
||||||
* {@value #MAX_PCM_BUFFER_DURATION_US}.
|
* {@link #MAX_PCM_BUFFER_DURATION_US}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setMaxPcmBufferDurationUs(int maxPcmBufferDurationUs) {
|
public Builder setMaxPcmBufferDurationUs(int maxPcmBufferDurationUs) {
|
||||||
|
|
@ -100,7 +100,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the multiplication factor to apply to the minimum buffer size requested. Default is
|
* Sets the multiplication factor to apply to the minimum buffer size requested. Default is
|
||||||
* {@value #PCM_BUFFER_MULTIPLICATION_FACTOR}.
|
* {@link #PCM_BUFFER_MULTIPLICATION_FACTOR}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) {
|
public Builder setPcmBufferMultiplicationFactor(int pcmBufferMultiplicationFactor) {
|
||||||
|
|
@ -110,7 +110,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is
|
* Sets the length for passthrough {@link AudioTrack} buffers, in microseconds. Default is
|
||||||
* {@value #PASSTHROUGH_BUFFER_DURATION_US}.
|
* {@link #PASSTHROUGH_BUFFER_DURATION_US}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setPassthroughBufferDurationUs(int passthroughBufferDurationUs) {
|
public Builder setPassthroughBufferDurationUs(int passthroughBufferDurationUs) {
|
||||||
|
|
@ -119,7 +119,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@value
|
* The length for offload {@link AudioTrack} buffers, in microseconds. Default is {@link
|
||||||
* #OFFLOAD_BUFFER_DURATION_US}.
|
* #OFFLOAD_BUFFER_DURATION_US}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
|
|
@ -130,7 +130,7 @@ public class DefaultAudioTrackBufferSizeProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the multiplication factor to apply to the passthrough buffer for AC3 to avoid underruns
|
* Sets the multiplication factor to apply to the passthrough buffer for AC3 to avoid underruns
|
||||||
* on some devices (e.g., Broadcom 7271). Default is {@value #AC3_BUFFER_MULTIPLICATION_FACTOR}.
|
* on some devices (e.g., Broadcom 7271). Default is {@link #AC3_BUFFER_MULTIPLICATION_FACTOR}.
|
||||||
*/
|
*/
|
||||||
@CanIgnoreReturnValue
|
@CanIgnoreReturnValue
|
||||||
public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) {
|
public Builder setAc3BufferMultiplicationFactor(int ac3BufferMultiplicationFactor) {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||||
|
|
||||||
private int codecMaxInputSize;
|
private int codecMaxInputSize;
|
||||||
private boolean codecNeedsDiscardChannelsWorkaround;
|
private boolean codecNeedsDiscardChannelsWorkaround;
|
||||||
|
@Nullable private Format inputFormat;
|
||||||
/** Codec used for DRM decryption only in passthrough and offload. */
|
/** Codec used for DRM decryption only in passthrough and offload. */
|
||||||
@Nullable private Format decryptOnlyCodecFormat;
|
@Nullable private Format decryptOnlyCodecFormat;
|
||||||
|
|
||||||
|
|
@ -500,8 +501,9 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||||
@Nullable
|
@Nullable
|
||||||
protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder)
|
protected DecoderReuseEvaluation onInputFormatChanged(FormatHolder formatHolder)
|
||||||
throws ExoPlaybackException {
|
throws ExoPlaybackException {
|
||||||
|
inputFormat = checkNotNull(formatHolder.format);
|
||||||
@Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder);
|
@Nullable DecoderReuseEvaluation evaluation = super.onInputFormatChanged(formatHolder);
|
||||||
eventDispatcher.inputFormatChanged(formatHolder.format, evaluation);
|
eventDispatcher.inputFormatChanged(inputFormat, evaluation);
|
||||||
return evaluation;
|
return evaluation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,6 +606,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||||
@Override
|
@Override
|
||||||
protected void onDisabled() {
|
protected void onDisabled() {
|
||||||
audioSinkNeedsReset = true;
|
audioSinkNeedsReset = true;
|
||||||
|
inputFormat = null;
|
||||||
try {
|
try {
|
||||||
audioSink.flush();
|
audioSink.flush();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -711,7 +714,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||||
fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount);
|
fullyConsumed = audioSink.handleBuffer(buffer, bufferPresentationTimeUs, sampleCount);
|
||||||
} catch (InitializationException e) {
|
} catch (InitializationException e) {
|
||||||
throw createRendererException(
|
throw createRendererException(
|
||||||
e, e.format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED);
|
e, inputFormat, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED);
|
||||||
} catch (WriteException e) {
|
} catch (WriteException e) {
|
||||||
throw createRendererException(
|
throw createRendererException(
|
||||||
e, format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED);
|
e, format, e.isRecoverable, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED);
|
||||||
|
|
|
||||||
|
|
@ -136,9 +136,10 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
||||||
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
private final LoadErrorHandlingPolicy loadErrorHandlingPolicy;
|
||||||
private final PlayerId playerId;
|
private final PlayerId playerId;
|
||||||
|
|
||||||
/* package */ final MediaDrmCallback callback;
|
private final MediaDrmCallback callback;
|
||||||
/* package */ final UUID uuid;
|
private final UUID uuid;
|
||||||
/* package */ final ResponseHandler responseHandler;
|
private final Looper playbackLooper;
|
||||||
|
private final ResponseHandler responseHandler;
|
||||||
|
|
||||||
private @DrmSession.State int state;
|
private @DrmSession.State int state;
|
||||||
private int referenceCount;
|
private int referenceCount;
|
||||||
|
|
@ -209,10 +210,12 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
||||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy;
|
||||||
this.playerId = playerId;
|
this.playerId = playerId;
|
||||||
state = STATE_OPENING;
|
state = STATE_OPENING;
|
||||||
|
this.playbackLooper = playbackLooper;
|
||||||
responseHandler = new ResponseHandler(playbackLooper);
|
responseHandler = new ResponseHandler(playbackLooper);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasSessionId(byte[] sessionId) {
|
public boolean hasSessionId(byte[] sessionId) {
|
||||||
|
verifyPlaybackThread();
|
||||||
return Arrays.equals(this.sessionId, sessionId);
|
return Arrays.equals(this.sessionId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,50 +258,59 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final @DrmSession.State int getState() {
|
public final @DrmSession.State int getState() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean playClearSamplesWithoutKeys() {
|
public boolean playClearSamplesWithoutKeys() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return playClearSamplesWithoutKeys;
|
return playClearSamplesWithoutKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Nullable
|
@Nullable
|
||||||
public final DrmSessionException getError() {
|
public final DrmSessionException getError() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return state == STATE_ERROR ? lastException : null;
|
return state == STATE_ERROR ? lastException : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final UUID getSchemeUuid() {
|
public final UUID getSchemeUuid() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return uuid;
|
return uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Nullable
|
@Nullable
|
||||||
public final CryptoConfig getCryptoConfig() {
|
public final CryptoConfig getCryptoConfig() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return cryptoConfig;
|
return cryptoConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Nullable
|
@Nullable
|
||||||
public Map<String, String> queryKeyStatus() {
|
public Map<String, String> queryKeyStatus() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId);
|
return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Nullable
|
@Nullable
|
||||||
public byte[] getOfflineLicenseKeySetId() {
|
public byte[] getOfflineLicenseKeySetId() {
|
||||||
|
verifyPlaybackThread();
|
||||||
return offlineLicenseKeySetId;
|
return offlineLicenseKeySetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean requiresSecureDecoder(String mimeType) {
|
public boolean requiresSecureDecoder(String mimeType) {
|
||||||
|
verifyPlaybackThread();
|
||||||
return mediaDrm.requiresSecureDecoder(checkStateNotNull(sessionId), mimeType);
|
return mediaDrm.requiresSecureDecoder(checkStateNotNull(sessionId), mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
|
public void acquire(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
|
||||||
|
verifyPlaybackThread();
|
||||||
if (referenceCount < 0) {
|
if (referenceCount < 0) {
|
||||||
Log.e(TAG, "Session reference count less than zero: " + referenceCount);
|
Log.e(TAG, "Session reference count less than zero: " + referenceCount);
|
||||||
referenceCount = 0;
|
referenceCount = 0;
|
||||||
|
|
@ -326,6 +338,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
|
public void release(@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher) {
|
||||||
|
verifyPlaybackThread();
|
||||||
if (referenceCount <= 0) {
|
if (referenceCount <= 0) {
|
||||||
Log.e(TAG, "release() called on a session that's already fully released.");
|
Log.e(TAG, "release() called on a session that's already fully released.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -561,6 +574,18 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void verifyPlaybackThread() {
|
||||||
|
if (Thread.currentThread() != playbackLooper.getThread()) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"DefaultDrmSession accessed on the wrong thread.\nCurrent thread: "
|
||||||
|
+ Thread.currentThread().getName()
|
||||||
|
+ "\nExpected thread: "
|
||||||
|
+ playbackLooper.getThread().getName(),
|
||||||
|
new IllegalStateException());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Internal classes.
|
// Internal classes.
|
||||||
|
|
||||||
@SuppressLint("HandlerLeak")
|
@SuppressLint("HandlerLeak")
|
||||||
|
|
|
||||||
|
|
@ -471,6 +471,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final void prepare() {
|
public final void prepare() {
|
||||||
|
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
|
||||||
if (prepareCallsCount++ != 0) {
|
if (prepareCallsCount++ != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +488,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final void release() {
|
public final void release() {
|
||||||
|
verifyPlaybackThread(/* allowBeforeSetPlayer= */ true);
|
||||||
if (--prepareCallsCount != 0) {
|
if (--prepareCallsCount != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -513,6 +515,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
@Override
|
@Override
|
||||||
public DrmSessionReference preacquireSession(
|
public DrmSessionReference preacquireSession(
|
||||||
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
|
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
|
||||||
|
// Don't verify the playback thread, preacquireSession can be called from any thread.
|
||||||
checkState(prepareCallsCount > 0);
|
checkState(prepareCallsCount > 0);
|
||||||
checkStateNotNull(playbackLooper);
|
checkStateNotNull(playbackLooper);
|
||||||
PreacquiredSessionReference preacquiredSessionReference =
|
PreacquiredSessionReference preacquiredSessionReference =
|
||||||
|
|
@ -525,6 +528,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
@Nullable
|
@Nullable
|
||||||
public DrmSession acquireSession(
|
public DrmSession acquireSession(
|
||||||
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
|
@Nullable DrmSessionEventListener.EventDispatcher eventDispatcher, Format format) {
|
||||||
|
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
|
||||||
checkState(prepareCallsCount > 0);
|
checkState(prepareCallsCount > 0);
|
||||||
checkStateNotNull(playbackLooper);
|
checkStateNotNull(playbackLooper);
|
||||||
return acquireSession(
|
return acquireSession(
|
||||||
|
|
@ -599,6 +603,7 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @C.CryptoType int getCryptoType(Format format) {
|
public @C.CryptoType int getCryptoType(Format format) {
|
||||||
|
verifyPlaybackThread(/* allowBeforeSetPlayer= */ false);
|
||||||
@C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType();
|
@C.CryptoType int cryptoType = checkNotNull(exoMediaDrm).getCryptoType();
|
||||||
if (format.drmInitData == null) {
|
if (format.drmInitData == null) {
|
||||||
int trackType = MimeTypes.getTrackType(format.sampleMimeType);
|
int trackType = MimeTypes.getTrackType(format.sampleMimeType);
|
||||||
|
|
@ -817,6 +822,23 @@ public class DefaultDrmSessionManager implements DrmSessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void verifyPlaybackThread(boolean allowBeforeSetPlayer) {
|
||||||
|
if (allowBeforeSetPlayer && playbackLooper == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"DefaultDrmSessionManager accessed before setPlayer(), possibly on the wrong thread.",
|
||||||
|
new IllegalStateException());
|
||||||
|
} else if (Thread.currentThread() != checkNotNull(playbackLooper).getThread()) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"DefaultDrmSessionManager accessed on the wrong thread.\nCurrent thread: "
|
||||||
|
+ Thread.currentThread().getName()
|
||||||
|
+ "\nExpected thread: "
|
||||||
|
+ playbackLooper.getThread().getName(),
|
||||||
|
new IllegalStateException());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}.
|
* Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import android.media.MediaDrm;
|
||||||
import android.os.ConditionVariable;
|
import android.os.ConditionVariable;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.HandlerThread;
|
import android.os.HandlerThread;
|
||||||
|
import android.os.Looper;
|
||||||
import android.util.Pair;
|
import android.util.Pair;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
@ -31,8 +32,11 @@ import androidx.media3.exoplayer.analytics.PlayerId;
|
||||||
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager.Mode;
|
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager.Mode;
|
||||||
import androidx.media3.exoplayer.drm.DrmSession.DrmSessionException;
|
import androidx.media3.exoplayer.drm.DrmSession.DrmSessionException;
|
||||||
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
|
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
|
||||||
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
|
|
||||||
/** Helper class to download, renew and release offline licenses. */
|
/** Helper class to download, renew and release offline licenses. */
|
||||||
@RequiresApi(18)
|
@RequiresApi(18)
|
||||||
|
|
@ -42,9 +46,10 @@ public final class OfflineLicenseHelper {
|
||||||
private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA =
|
private static final Format FORMAT_WITH_EMPTY_DRM_INIT_DATA =
|
||||||
new Format.Builder().setDrmInitData(new DrmInitData()).build();
|
new Format.Builder().setDrmInitData(new DrmInitData()).build();
|
||||||
|
|
||||||
private final ConditionVariable conditionVariable;
|
private final ConditionVariable drmListenerConditionVariable;
|
||||||
private final DefaultDrmSessionManager drmSessionManager;
|
private final DefaultDrmSessionManager drmSessionManager;
|
||||||
private final HandlerThread handlerThread;
|
private final HandlerThread handlerThread;
|
||||||
|
private final Handler handler;
|
||||||
private final DrmSessionEventListener.EventDispatcher eventDispatcher;
|
private final DrmSessionEventListener.EventDispatcher eventDispatcher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -156,28 +161,29 @@ public final class OfflineLicenseHelper {
|
||||||
this.eventDispatcher = eventDispatcher;
|
this.eventDispatcher = eventDispatcher;
|
||||||
handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper");
|
handlerThread = new HandlerThread("ExoPlayer:OfflineLicenseHelper");
|
||||||
handlerThread.start();
|
handlerThread.start();
|
||||||
conditionVariable = new ConditionVariable();
|
handler = new Handler(handlerThread.getLooper());
|
||||||
|
drmListenerConditionVariable = new ConditionVariable();
|
||||||
DrmSessionEventListener eventListener =
|
DrmSessionEventListener eventListener =
|
||||||
new DrmSessionEventListener() {
|
new DrmSessionEventListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
public void onDrmKeysLoaded(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
||||||
conditionVariable.open();
|
drmListenerConditionVariable.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDrmSessionManagerError(
|
public void onDrmSessionManagerError(
|
||||||
int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) {
|
int windowIndex, @Nullable MediaPeriodId mediaPeriodId, Exception e) {
|
||||||
conditionVariable.open();
|
drmListenerConditionVariable.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
public void onDrmKeysRestored(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
||||||
conditionVariable.open();
|
drmListenerConditionVariable.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
public void onDrmKeysRemoved(int windowIndex, @Nullable MediaPeriodId mediaPeriodId) {
|
||||||
conditionVariable.open();
|
drmListenerConditionVariable.open();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener);
|
eventDispatcher.addEventListener(new Handler(handlerThread.getLooper()), eventListener);
|
||||||
|
|
@ -193,7 +199,8 @@ public final class OfflineLicenseHelper {
|
||||||
*/
|
*/
|
||||||
public synchronized byte[] downloadLicense(Format format) throws DrmSessionException {
|
public synchronized byte[] downloadLicense(Format format) throws DrmSessionException {
|
||||||
Assertions.checkArgument(format.drmInitData != null);
|
Assertions.checkArgument(format.drmInitData != null);
|
||||||
return blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, format);
|
return acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
|
||||||
|
DefaultDrmSessionManager.MODE_DOWNLOAD, /* offlineLicenseKeySetId= */ null, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -206,7 +213,7 @@ public final class OfflineLicenseHelper {
|
||||||
public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId)
|
public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId)
|
||||||
throws DrmSessionException {
|
throws DrmSessionException {
|
||||||
Assertions.checkNotNull(offlineLicenseKeySetId);
|
Assertions.checkNotNull(offlineLicenseKeySetId);
|
||||||
return blockingKeyRequest(
|
return acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
|
||||||
DefaultDrmSessionManager.MODE_DOWNLOAD,
|
DefaultDrmSessionManager.MODE_DOWNLOAD,
|
||||||
offlineLicenseKeySetId,
|
offlineLicenseKeySetId,
|
||||||
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
||||||
|
|
@ -221,7 +228,7 @@ public final class OfflineLicenseHelper {
|
||||||
public synchronized void releaseLicense(byte[] offlineLicenseKeySetId)
|
public synchronized void releaseLicense(byte[] offlineLicenseKeySetId)
|
||||||
throws DrmSessionException {
|
throws DrmSessionException {
|
||||||
Assertions.checkNotNull(offlineLicenseKeySetId);
|
Assertions.checkNotNull(offlineLicenseKeySetId);
|
||||||
blockingKeyRequest(
|
acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
|
||||||
DefaultDrmSessionManager.MODE_RELEASE,
|
DefaultDrmSessionManager.MODE_RELEASE,
|
||||||
offlineLicenseKeySetId,
|
offlineLicenseKeySetId,
|
||||||
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
||||||
|
|
@ -237,25 +244,39 @@ public final class OfflineLicenseHelper {
|
||||||
public synchronized Pair<Long, Long> getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId)
|
public synchronized Pair<Long, Long> getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId)
|
||||||
throws DrmSessionException {
|
throws DrmSessionException {
|
||||||
Assertions.checkNotNull(offlineLicenseKeySetId);
|
Assertions.checkNotNull(offlineLicenseKeySetId);
|
||||||
drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET);
|
DrmSession drmSession;
|
||||||
drmSessionManager.prepare();
|
try {
|
||||||
DrmSession drmSession =
|
drmSession =
|
||||||
openBlockingKeyRequest(
|
acquireFirstSessionOnHandlerThread(
|
||||||
DefaultDrmSessionManager.MODE_QUERY,
|
DefaultDrmSessionManager.MODE_QUERY,
|
||||||
offlineLicenseKeySetId,
|
offlineLicenseKeySetId,
|
||||||
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
FORMAT_WITH_EMPTY_DRM_INIT_DATA);
|
||||||
DrmSessionException error = drmSession.getError();
|
} catch (DrmSessionException e) {
|
||||||
Pair<Long, Long> licenseDurationRemainingSec =
|
if (e.getCause() instanceof KeysExpiredException) {
|
||||||
WidevineUtil.getLicenseDurationRemainingSec(drmSession);
|
|
||||||
drmSession.release(eventDispatcher);
|
|
||||||
drmSessionManager.release();
|
|
||||||
if (error != null) {
|
|
||||||
if (error.getCause() instanceof KeysExpiredException) {
|
|
||||||
return Pair.create(0L, 0L);
|
return Pair.create(0L, 0L);
|
||||||
}
|
}
|
||||||
throw error;
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
SettableFuture<Pair<Long, Long>> licenseDurationRemainingSec = SettableFuture.create();
|
||||||
|
handler.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
licenseDurationRemainingSec.set(
|
||||||
|
Assertions.checkNotNull(WidevineUtil.getLicenseDurationRemainingSec(drmSession)));
|
||||||
|
} catch (Throwable e) {
|
||||||
|
licenseDurationRemainingSec.setException(e);
|
||||||
|
} finally {
|
||||||
|
drmSession.release(eventDispatcher);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return licenseDurationRemainingSec.get();
|
||||||
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
} finally {
|
||||||
|
releaseManagerOnHandlerThread();
|
||||||
}
|
}
|
||||||
return Assertions.checkNotNull(licenseDurationRemainingSec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Releases the helper. Should be called when the helper is no longer required. */
|
/** Releases the helper. Should be called when the helper is no longer required. */
|
||||||
|
|
@ -263,30 +284,146 @@ public final class OfflineLicenseHelper {
|
||||||
handlerThread.quit();
|
handlerThread.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] blockingKeyRequest(
|
/**
|
||||||
|
* Returns the result of {@link DrmSession#getOfflineLicenseKeySetId()}, or throws {@link
|
||||||
|
* NullPointerException} if it's null.
|
||||||
|
*
|
||||||
|
* <p>This method takes care of acquiring and releasing the {@link DrmSessionManager} and {@link
|
||||||
|
* DrmSession} instances needed.
|
||||||
|
*/
|
||||||
|
private byte[] acquireSessionAndGetOfflineLicenseKeySetIdOnHandlerThread(
|
||||||
@Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format)
|
@Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format)
|
||||||
throws DrmSessionException {
|
throws DrmSessionException {
|
||||||
drmSessionManager.setPlayer(handlerThread.getLooper(), PlayerId.UNSET);
|
DrmSession drmSession =
|
||||||
drmSessionManager.prepare();
|
acquireFirstSessionOnHandlerThread(licenseMode, offlineLicenseKeySetId, format);
|
||||||
DrmSession drmSession = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, format);
|
|
||||||
DrmSessionException error = drmSession.getError();
|
SettableFuture<byte @NullableType []> keySetId = SettableFuture.create();
|
||||||
byte[] keySetId = drmSession.getOfflineLicenseKeySetId();
|
handler.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
keySetId.set(drmSession.getOfflineLicenseKeySetId());
|
||||||
|
} catch (Throwable e) {
|
||||||
|
keySetId.setException(e);
|
||||||
|
} finally {
|
||||||
drmSession.release(eventDispatcher);
|
drmSession.release(eventDispatcher);
|
||||||
drmSessionManager.release();
|
|
||||||
if (error != null) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return Assertions.checkNotNull(keySetId);
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Assertions.checkNotNull(keySetId.get());
|
||||||
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
} finally {
|
||||||
|
releaseManagerOnHandlerThread();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DrmSession openBlockingKeyRequest(
|
/**
|
||||||
@Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format) {
|
* Calls {@link DrmSessionManager#acquireSession(DrmSessionEventListener.EventDispatcher, Format)}
|
||||||
|
* on {@link #handlerThread} and blocks until a callback is received via {@link
|
||||||
|
* DrmSessionEventListener}.
|
||||||
|
*
|
||||||
|
* <p>If key loading failed and {@link DrmSession#getState()} returns {@link
|
||||||
|
* DrmSession#STATE_ERROR} then this method releases the session and throws {@link
|
||||||
|
* DrmSession#getError()}.
|
||||||
|
*
|
||||||
|
* <p>Callers are responsible for the following:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Ensuring the {@link
|
||||||
|
* DrmSessionManager#acquireSession(DrmSessionEventListener.EventDispatcher, Format)} call
|
||||||
|
* will trigger a callback to {@link DrmSessionEventListener} (e.g. it will load new keys).
|
||||||
|
* If not, this method will block forever.
|
||||||
|
* <li>Releasing the returned {@link DrmSession} instance (on {@link #handlerThread}).
|
||||||
|
* <li>Releasing {@link #drmSessionManager} if a {@link DrmSession} instance is returned (the
|
||||||
|
* manager will be released before an exception is thrown).
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private DrmSession acquireFirstSessionOnHandlerThread(
|
||||||
|
@Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, Format format)
|
||||||
|
throws DrmSessionException {
|
||||||
Assertions.checkNotNull(format.drmInitData);
|
Assertions.checkNotNull(format.drmInitData);
|
||||||
|
SettableFuture<DrmSession> drmSessionFuture = SettableFuture.create();
|
||||||
|
drmListenerConditionVariable.close();
|
||||||
|
handler.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
drmSessionManager.setPlayer(Assertions.checkNotNull(Looper.myLooper()), PlayerId.UNSET);
|
||||||
|
drmSessionManager.prepare();
|
||||||
|
try {
|
||||||
drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId);
|
drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId);
|
||||||
conditionVariable.close();
|
drmSessionFuture.set(
|
||||||
DrmSession drmSession = drmSessionManager.acquireSession(eventDispatcher, format);
|
Assertions.checkNotNull(
|
||||||
// Block current thread until key loading is finished
|
drmSessionManager.acquireSession(eventDispatcher, format)));
|
||||||
conditionVariable.block();
|
} catch (Throwable e) {
|
||||||
return Assertions.checkNotNull(drmSession);
|
drmSessionManager.release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
drmSessionFuture.setException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DrmSession drmSession;
|
||||||
|
try {
|
||||||
|
drmSession = drmSessionFuture.get();
|
||||||
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// drmListenerConditionVariable will be opened by a callback to this.eventDispatcher when key
|
||||||
|
// loading is complete (drmSession.state == STATE_OPENED_WITH_KEYS) or has failed
|
||||||
|
// (drmSession.state == STATE_ERROR).
|
||||||
|
drmListenerConditionVariable.block();
|
||||||
|
|
||||||
|
SettableFuture<@NullableType DrmSessionException> drmSessionErrorFuture =
|
||||||
|
SettableFuture.create();
|
||||||
|
handler.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
DrmSessionException drmSessionError = drmSession.getError();
|
||||||
|
if (drmSession.getState() == DrmSession.STATE_ERROR) {
|
||||||
|
drmSession.release(eventDispatcher);
|
||||||
|
drmSessionManager.release();
|
||||||
|
}
|
||||||
|
drmSessionErrorFuture.set(drmSessionError);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
drmSessionErrorFuture.setException(e);
|
||||||
|
drmSession.release(eventDispatcher);
|
||||||
|
drmSessionManager.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
DrmSessionException drmSessionError = drmSessionErrorFuture.get();
|
||||||
|
if (drmSessionError != null) {
|
||||||
|
throw drmSessionError;
|
||||||
|
} else {
|
||||||
|
return drmSession;
|
||||||
|
}
|
||||||
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls {@link DrmSessionManager#release()} on {@link #handlerThread} and blocks until it's
|
||||||
|
* complete.
|
||||||
|
*/
|
||||||
|
private void releaseManagerOnHandlerThread() {
|
||||||
|
SettableFuture<Void> result = SettableFuture.create();
|
||||||
|
handler.post(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
drmSessionManager.release();
|
||||||
|
result.set(null);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
result.setException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
result.get();
|
||||||
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -642,14 +642,22 @@ public abstract class MediaCodecRenderer extends BaseRenderer {
|
||||||
@Override
|
@Override
|
||||||
protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs)
|
protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs)
|
||||||
throws ExoPlaybackException {
|
throws ExoPlaybackException {
|
||||||
if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET
|
if (outputStreamInfo.streamOffsetUs == C.TIME_UNSET) {
|
||||||
|| (pendingOutputStreamChanges.isEmpty()
|
// This is the first stream.
|
||||||
&& lastProcessedOutputBufferTimeUs != C.TIME_UNSET
|
|
||||||
&& lastProcessedOutputBufferTimeUs >= largestQueuedPresentationTimeUs)) {
|
|
||||||
// This is the first stream, or the previous has been fully output already.
|
|
||||||
setOutputStreamInfo(
|
setOutputStreamInfo(
|
||||||
new OutputStreamInfo(
|
new OutputStreamInfo(
|
||||||
/* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs));
|
/* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs));
|
||||||
|
} else if (pendingOutputStreamChanges.isEmpty()
|
||||||
|
&& (largestQueuedPresentationTimeUs == C.TIME_UNSET
|
||||||
|
|| (lastProcessedOutputBufferTimeUs != C.TIME_UNSET
|
||||||
|
&& lastProcessedOutputBufferTimeUs >= largestQueuedPresentationTimeUs))) {
|
||||||
|
// All previous streams have never queued any samples or have been fully output already.
|
||||||
|
setOutputStreamInfo(
|
||||||
|
new OutputStreamInfo(
|
||||||
|
/* previousStreamLastBufferTimeUs= */ C.TIME_UNSET, startPositionUs, offsetUs));
|
||||||
|
if (outputStreamInfo.streamOffsetUs != C.TIME_UNSET) {
|
||||||
|
onProcessedStreamChange();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingOutputStreamChanges.add(
|
pendingOutputStreamChanges.add(
|
||||||
new OutputStreamInfo(largestQueuedPresentationTimeUs, startPositionUs, offsetUs));
|
new OutputStreamInfo(largestQueuedPresentationTimeUs, startPositionUs, offsetUs));
|
||||||
|
|
@ -1581,7 +1589,7 @@ public abstract class MediaCodecRenderer extends BaseRenderer {
|
||||||
@CallSuper
|
@CallSuper
|
||||||
protected void onProcessedOutputBuffer(long presentationTimeUs) {
|
protected void onProcessedOutputBuffer(long presentationTimeUs) {
|
||||||
lastProcessedOutputBufferTimeUs = presentationTimeUs;
|
lastProcessedOutputBufferTimeUs = presentationTimeUs;
|
||||||
if (!pendingOutputStreamChanges.isEmpty()
|
while (!pendingOutputStreamChanges.isEmpty()
|
||||||
&& presentationTimeUs >= pendingOutputStreamChanges.peek().previousStreamLastBufferTimeUs) {
|
&& presentationTimeUs >= pendingOutputStreamChanges.peek().previousStreamLastBufferTimeUs) {
|
||||||
setOutputStreamInfo(pendingOutputStreamChanges.poll());
|
setOutputStreamInfo(pendingOutputStreamChanges.poll());
|
||||||
onProcessedStreamChange();
|
onProcessedStreamChange();
|
||||||
|
|
|
||||||
|
|
@ -71,17 +71,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
* <li>{@code DashMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
* <li>{@code DashMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
||||||
* ends in '.mpd' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
|
* ends in '.mpd' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
|
||||||
* explicitly set to {@link MimeTypes#APPLICATION_MPD} (Requires the <a
|
* explicitly set to {@link MimeTypes#APPLICATION_MPD} (Requires the <a
|
||||||
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules">exoplayer-dash module
|
* href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-dash
|
||||||
* to be added</a> to the app).
|
* module to be added</a> to the app).
|
||||||
* <li>{@code HlsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
* <li>{@code HlsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
||||||
* ends in '.m3u8' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
|
* ends in '.m3u8' or if its {@link MediaItem.LocalConfiguration#mimeType mimeType field} is
|
||||||
* explicitly set to {@link MimeTypes#APPLICATION_M3U8} (Requires the <a
|
* explicitly set to {@link MimeTypes#APPLICATION_M3U8} (Requires the <a
|
||||||
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules">exoplayer-hls module to
|
* href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">exoplayer-hls
|
||||||
* be added</a> to the app).
|
* module to be added</a> to the app).
|
||||||
* <li>{@code SsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
* <li>{@code SsMediaSource.Factory} if the item's {@link MediaItem.LocalConfiguration#uri uri}
|
||||||
* ends in '.ism', '.ism/Manifest' or if its {@link MediaItem.LocalConfiguration#mimeType
|
* ends in '.ism', '.ism/Manifest' or if its {@link MediaItem.LocalConfiguration#mimeType
|
||||||
* mimeType field} is explicitly set to {@link MimeTypes#APPLICATION_SS} (Requires the <a
|
* mimeType field} is explicitly set to {@link MimeTypes#APPLICATION_SS} (Requires the <a
|
||||||
* href="https://exoplayer.dev/hello-world.html#add-exoplayer-modules">
|
* href="https://developer.android.com/guide/topics/media/exoplayer/hello-world#add-exoplayer-modules">
|
||||||
* exoplayer-smoothstreaming module to be added</a> to the app).
|
* exoplayer-smoothstreaming module to be added</a> to the app).
|
||||||
* <li>{@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link
|
* <li>{@link ProgressiveMediaSource.Factory} serves as a fallback if the item's {@link
|
||||||
* MediaItem.LocalConfiguration#uri uri} doesn't match one of the above. It tries to infer the
|
* MediaItem.LocalConfiguration#uri uri} doesn't match one of the above. It tries to infer the
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,115 @@ public class MediaCodecRendererTest {
|
||||||
inOrder.verify(renderer).onProcessedOutputBuffer(600);
|
inOrder.verify(renderer).onProcessedOutputBuffer(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void
|
||||||
|
render_withReplaceStreamAfterInitialEmptySampleStream_triggersOutputCallbacksInCorrectOrder()
|
||||||
|
throws Exception {
|
||||||
|
Format format1 =
|
||||||
|
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build();
|
||||||
|
Format format2 =
|
||||||
|
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build();
|
||||||
|
FakeSampleStream fakeSampleStream1 = createFakeSampleStream(format1 /* no samples */);
|
||||||
|
FakeSampleStream fakeSampleStream2 =
|
||||||
|
createFakeSampleStream(format2, /* sampleTimesUs...= */ 0, 100, 200);
|
||||||
|
MediaCodecRenderer renderer = spy(new TestRenderer());
|
||||||
|
renderer.init(/* index= */ 0, PlayerId.UNSET);
|
||||||
|
|
||||||
|
renderer.enable(
|
||||||
|
RendererConfiguration.DEFAULT,
|
||||||
|
new Format[] {format1},
|
||||||
|
fakeSampleStream1,
|
||||||
|
/* positionUs= */ 0,
|
||||||
|
/* joining= */ false,
|
||||||
|
/* mayRenderStartOfStream= */ true,
|
||||||
|
/* startPositionUs= */ 0,
|
||||||
|
/* offsetUs= */ 0);
|
||||||
|
renderer.start();
|
||||||
|
long positionUs = 0;
|
||||||
|
while (!renderer.hasReadStreamToEnd()) {
|
||||||
|
renderer.render(positionUs, SystemClock.elapsedRealtime());
|
||||||
|
positionUs += 100;
|
||||||
|
}
|
||||||
|
renderer.replaceStream(
|
||||||
|
new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 0, /* offsetUs= */ 0);
|
||||||
|
renderer.setCurrentStreamFinal();
|
||||||
|
while (!renderer.isEnded()) {
|
||||||
|
renderer.render(positionUs, SystemClock.elapsedRealtime());
|
||||||
|
positionUs += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
InOrder inOrder = inOrder(renderer);
|
||||||
|
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
|
||||||
|
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
|
||||||
|
inOrder.verify(renderer).onProcessedStreamChange();
|
||||||
|
inOrder.verify(renderer).onOutputFormatChanged(eq(format2), any());
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(0);
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(100);
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void
|
||||||
|
render_withReplaceStreamAfterIntermittentEmptySampleStream_triggersOutputCallbacksInCorrectOrder()
|
||||||
|
throws Exception {
|
||||||
|
Format format1 =
|
||||||
|
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1000).build();
|
||||||
|
Format format2 =
|
||||||
|
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(1500).build();
|
||||||
|
Format format3 =
|
||||||
|
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).setAverageBitrate(2000).build();
|
||||||
|
FakeSampleStream fakeSampleStream1 =
|
||||||
|
createFakeSampleStream(format1, /* sampleTimesUs...= */ 0, 100);
|
||||||
|
FakeSampleStream fakeSampleStream2 = createFakeSampleStream(format2 /* no samples */);
|
||||||
|
FakeSampleStream fakeSampleStream3 =
|
||||||
|
createFakeSampleStream(format3, /* sampleTimesUs...= */ 0, 100, 200);
|
||||||
|
MediaCodecRenderer renderer = spy(new TestRenderer());
|
||||||
|
renderer.init(/* index= */ 0, PlayerId.UNSET);
|
||||||
|
|
||||||
|
renderer.enable(
|
||||||
|
RendererConfiguration.DEFAULT,
|
||||||
|
new Format[] {format1},
|
||||||
|
fakeSampleStream1,
|
||||||
|
/* positionUs= */ 0,
|
||||||
|
/* joining= */ false,
|
||||||
|
/* mayRenderStartOfStream= */ true,
|
||||||
|
/* startPositionUs= */ 0,
|
||||||
|
/* offsetUs= */ 0);
|
||||||
|
renderer.start();
|
||||||
|
long positionUs = 0;
|
||||||
|
while (!renderer.hasReadStreamToEnd()) {
|
||||||
|
renderer.render(positionUs, SystemClock.elapsedRealtime());
|
||||||
|
positionUs += 100;
|
||||||
|
}
|
||||||
|
renderer.replaceStream(
|
||||||
|
new Format[] {format2}, fakeSampleStream2, /* startPositionUs= */ 200, /* offsetUs= */ 200);
|
||||||
|
while (!renderer.hasReadStreamToEnd()) {
|
||||||
|
renderer.render(positionUs, SystemClock.elapsedRealtime());
|
||||||
|
positionUs += 100;
|
||||||
|
}
|
||||||
|
renderer.replaceStream(
|
||||||
|
new Format[] {format3}, fakeSampleStream3, /* startPositionUs= */ 200, /* offsetUs= */ 200);
|
||||||
|
renderer.setCurrentStreamFinal();
|
||||||
|
while (!renderer.isEnded()) {
|
||||||
|
renderer.render(positionUs, SystemClock.elapsedRealtime());
|
||||||
|
positionUs += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
InOrder inOrder = inOrder(renderer);
|
||||||
|
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(0);
|
||||||
|
inOrder.verify(renderer).onOutputFormatChanged(eq(format1), any());
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(0);
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(100);
|
||||||
|
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200);
|
||||||
|
inOrder.verify(renderer).onProcessedStreamChange();
|
||||||
|
inOrder.verify(renderer).onOutputStreamOffsetUsChanged(200);
|
||||||
|
inOrder.verify(renderer).onProcessedStreamChange();
|
||||||
|
inOrder.verify(renderer).onOutputFormatChanged(eq(format3), any());
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(200);
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(300);
|
||||||
|
inOrder.verify(renderer).onProcessedOutputBuffer(400);
|
||||||
|
}
|
||||||
|
|
||||||
private FakeSampleStream createFakeSampleStream(Format format, long... sampleTimesUs) {
|
private FakeSampleStream createFakeSampleStream(Format format, long... sampleTimesUs) {
|
||||||
ImmutableList.Builder<FakeSampleStream.FakeSampleStreamItem> sampleListBuilder =
|
ImmutableList.Builder<FakeSampleStream.FakeSampleStreamItem> sampleListBuilder =
|
||||||
ImmutableList.builder();
|
ImmutableList.builder();
|
||||||
|
|
|
||||||
|
|
@ -236,9 +236,12 @@ public class DefaultDashChunkSource implements DashChunkSource {
|
||||||
// Segments are aligned across representations, so any segment index will do.
|
// Segments are aligned across representations, so any segment index will do.
|
||||||
for (RepresentationHolder representationHolder : representationHolders) {
|
for (RepresentationHolder representationHolder : representationHolders) {
|
||||||
if (representationHolder.segmentIndex != null) {
|
if (representationHolder.segmentIndex != null) {
|
||||||
|
long segmentCount = representationHolder.getSegmentCount();
|
||||||
|
if (segmentCount == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
long segmentNum = representationHolder.getSegmentNum(positionUs);
|
long segmentNum = representationHolder.getSegmentNum(positionUs);
|
||||||
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
|
long firstSyncUs = representationHolder.getSegmentStartTimeUs(segmentNum);
|
||||||
long segmentCount = representationHolder.getSegmentCount();
|
|
||||||
long secondSyncUs =
|
long secondSyncUs =
|
||||||
firstSyncUs < positionUs
|
firstSyncUs < positionUs
|
||||||
&& (segmentCount == DashSegmentIndex.INDEX_UNBOUNDED
|
&& (segmentCount == DashSegmentIndex.INDEX_UNBOUNDED
|
||||||
|
|
@ -594,7 +597,7 @@ public class DefaultDashChunkSource implements DashChunkSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositionUs) {
|
private long getAvailableLiveDurationUs(long nowUnixTimeUs, long playbackPositionUs) {
|
||||||
if (!manifest.dynamic) {
|
if (!manifest.dynamic || representationHolders[0].getSegmentCount() == 0) {
|
||||||
return C.TIME_UNSET;
|
return C.TIME_UNSET;
|
||||||
}
|
}
|
||||||
long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs);
|
long lastSegmentNum = representationHolders[0].getLastAvailableSegmentNum(nowUnixTimeUs);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ locally. Instructions for doing this can be found in the [top level README][].
|
||||||
## Using the module
|
## Using the module
|
||||||
|
|
||||||
To use the module, follow the instructions on the
|
To use the module, follow the instructions on the
|
||||||
[Ad insertion page](https://exoplayer.dev/ad-insertion.html#declarative-ad-support)
|
[Ad insertion page](https://developer.android.com/guide/topics/media/exoplayer/ad-insertion#declarative-ad-support)
|
||||||
of the developer guide. The `AdsLoaderProvider` passed to the player's
|
of the developer guide. The `AdsLoaderProvider` passed to the player's
|
||||||
`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA
|
`DefaultMediaSourceFactory` should return an `ImaAdsLoader`. Note that the IMA
|
||||||
module only supports players that are accessed on the application's main thread.
|
module only supports players that are accessed on the application's main thread.
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ public final class ImaAdsLoader implements AdsLoader {
|
||||||
/**
|
/**
|
||||||
* Sets the duration in milliseconds for which the player must buffer while preloading an ad
|
* Sets the duration in milliseconds for which the player must buffer while preloading an ad
|
||||||
* group before that ad group is skipped and marked as having failed to load. Pass {@link
|
* group before that ad group is skipped and marked as having failed to load. Pass {@link
|
||||||
* C#TIME_UNSET} if there should be no such timeout. The default value is {@value
|
* C#TIME_UNSET} if there should be no such timeout. The default value is {@link
|
||||||
* #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms.
|
* #DEFAULT_AD_PRELOAD_TIMEOUT_MS} ms.
|
||||||
*
|
*
|
||||||
* <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that
|
* <p>The purpose of this timeout is to avoid playback getting stuck in the unexpected case that
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import androidx.media3.common.util.Log;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo;
|
import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo;
|
||||||
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException;
|
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException;
|
||||||
|
import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspUdpUnsupportedTransportException;
|
||||||
import androidx.media3.exoplayer.rtsp.RtspMessageChannel.InterleavedBinaryDataListener;
|
import androidx.media3.exoplayer.rtsp.RtspMessageChannel.InterleavedBinaryDataListener;
|
||||||
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo;
|
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo;
|
||||||
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader;
|
import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader;
|
||||||
|
|
@ -577,8 +578,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
receivedAuthorizationRequest = true;
|
receivedAuthorizationRequest = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// fall through: if unauthorized and no userInfo present, or previous authentication
|
// if unauthorized and no userInfo present, or previous authentication
|
||||||
// unsuccessful.
|
// unsuccessful, then dispatch RtspPlaybackException
|
||||||
|
dispatchRtspError(
|
||||||
|
new RtspPlaybackException(
|
||||||
|
RtspMessageUtil.toMethodString(requestMethod) + " " + response.status));
|
||||||
|
return;
|
||||||
|
case 461:
|
||||||
|
String exceptionMessage =
|
||||||
|
RtspMessageUtil.toMethodString(requestMethod) + " " + response.status;
|
||||||
|
// If request was SETUP with UDP transport protocol, then throw
|
||||||
|
// RtspUdpUnsupportedTransportException.
|
||||||
|
String transportHeaderValue =
|
||||||
|
checkNotNull(matchingRequest.headers.get(RtspHeaders.TRANSPORT));
|
||||||
|
dispatchRtspError(
|
||||||
|
requestMethod == METHOD_SETUP && !transportHeaderValue.contains("TCP")
|
||||||
|
? new RtspUdpUnsupportedTransportException(exceptionMessage)
|
||||||
|
: new RtspPlaybackException(exceptionMessage));
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
dispatchRtspError(
|
dispatchRtspError(
|
||||||
new RtspPlaybackException(
|
new RtspPlaybackException(
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
// using TCP. Retrying will setup new loadables, so will not retry with the current
|
// using TCP. Retrying will setup new loadables, so will not retry with the current
|
||||||
// loadables.
|
// loadables.
|
||||||
retryWithRtpTcp();
|
retryWithRtpTcp();
|
||||||
isUsingRtpTcp = true;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -644,8 +643,14 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlaybackError(RtspPlaybackException error) {
|
public void onPlaybackError(RtspPlaybackException error) {
|
||||||
|
if (error instanceof RtspMediaSource.RtspUdpUnsupportedTransportException && !isUsingRtpTcp) {
|
||||||
|
// Retry playback with TCP if we receive RtspUdpUnsupportedTransportException, and we are
|
||||||
|
// not already using TCP. Retrying will setup new loadables.
|
||||||
|
retryWithRtpTcp();
|
||||||
|
} else {
|
||||||
playbackException = error;
|
playbackException = error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSessionTimelineUpdated(
|
public void onSessionTimelineUpdated(
|
||||||
|
|
@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void retryWithRtpTcp() {
|
private void retryWithRtpTcp() {
|
||||||
|
// Retry should only run once.
|
||||||
|
isUsingRtpTcp = true;
|
||||||
|
|
||||||
rtspClient.retryWithRtpTcp();
|
rtspClient.retryWithRtpTcp();
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thrown when an exception or error is encountered during loading an RTSP stream. */
|
/** Thrown when an exception or error is encountered during loading an RTSP stream. */
|
||||||
public static final class RtspPlaybackException extends IOException {
|
public static class RtspPlaybackException extends IOException {
|
||||||
public RtspPlaybackException(String message) {
|
public RtspPlaybackException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +206,13 @@ public final class RtspMediaSource extends BaseMediaSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown when an RTSP Unsupported Transport error (461) is encountered during RTSP Setup. */
|
||||||
|
public static final class RtspUdpUnsupportedTransportException extends RtspPlaybackException {
|
||||||
|
public RtspUdpUnsupportedTransportException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final MediaItem mediaItem;
|
private final MediaItem mediaItem;
|
||||||
private final RtpDataChannel.Factory rtpDataChannelFactory;
|
private final RtpDataChannel.Factory rtpDataChannelFactory;
|
||||||
private final String userAgent;
|
private final String userAgent;
|
||||||
|
|
|
||||||
|
|
@ -453,4 +453,77 @@ public final class RtspClientTest {
|
||||||
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
|
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
|
||||||
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED);
|
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void connectServerAndClient_describeResponseRequiresAuthentication_doesNotUpdateTimeline()
|
||||||
|
throws Exception {
|
||||||
|
class ResponseProvider implements RtspServer.ResponseProvider {
|
||||||
|
@Override
|
||||||
|
public RtspResponse getOptionsResponse() {
|
||||||
|
return new RtspResponse(
|
||||||
|
/* status= */ 200,
|
||||||
|
new RtspHeaders.Builder().add(RtspHeaders.PUBLIC, "OPTIONS, DESCRIBE").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RtspResponse getDescribeResponse(Uri requestedUri, RtspHeaders headers) {
|
||||||
|
String authorizationHeader = headers.get(RtspHeaders.AUTHORIZATION);
|
||||||
|
if (authorizationHeader == null) {
|
||||||
|
return new RtspResponse(
|
||||||
|
/* status= */ 401,
|
||||||
|
new RtspHeaders.Builder()
|
||||||
|
.add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ))
|
||||||
|
.add(
|
||||||
|
RtspHeaders.WWW_AUTHENTICATE,
|
||||||
|
"Digest realm=\"RTSP server\","
|
||||||
|
+ " nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\","
|
||||||
|
+ " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")
|
||||||
|
.add(RtspHeaders.WWW_AUTHENTICATE, "BASIC realm=\"WallyWorld\"")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
if (!authorizationHeader.contains("Digest")) {
|
||||||
|
return new RtspResponse(
|
||||||
|
401,
|
||||||
|
new RtspHeaders.Builder()
|
||||||
|
.add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return RtspTestUtils.newDescribeResponseWithSdpMessage(
|
||||||
|
"v=0\r\n"
|
||||||
|
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
|
||||||
|
+ "s=Exoplayer test\r\n"
|
||||||
|
+ "t=0 0\r\n"
|
||||||
|
// The session is 50.46s long.
|
||||||
|
+ "a=range:npt=0-50.46\r\n",
|
||||||
|
rtpPacketStreamDumps,
|
||||||
|
requestedUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rtspServer = new RtspServer(new ResponseProvider());
|
||||||
|
|
||||||
|
AtomicBoolean timelineRequestFailed = new AtomicBoolean();
|
||||||
|
rtspClient =
|
||||||
|
new RtspClient(
|
||||||
|
new SessionInfoListener() {
|
||||||
|
@Override
|
||||||
|
public void onSessionTimelineUpdated(
|
||||||
|
RtspSessionTiming timing, ImmutableList<RtspMediaTrack> tracks) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSessionTimelineRequestFailed(
|
||||||
|
String message, @Nullable Throwable cause) {
|
||||||
|
timelineRequestFailed.set(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
EMPTY_PLAYBACK_LISTENER,
|
||||||
|
/* userAgent= */ "ExoPlayer:RtspClientTest",
|
||||||
|
RtspTestUtils.getTestUri(rtspServer.startAndGetPortNumber()),
|
||||||
|
SocketFactory.getDefault(),
|
||||||
|
/* debugLoggingEnabled= */ false);
|
||||||
|
rtspClient.start();
|
||||||
|
|
||||||
|
RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get);
|
||||||
|
assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
package androidx.media3.exoplayer.rtsp;
|
package androidx.media3.exoplayer.rtsp;
|
||||||
|
|
||||||
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
import static com.google.common.truth.Truth.assertThat;
|
import static com.google.common.truth.Truth.assertThat;
|
||||||
import static java.lang.Math.min;
|
import static java.lang.Math.min;
|
||||||
|
|
@ -42,11 +43,13 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper;
|
||||||
import androidx.test.core.app.ApplicationProvider;
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import javax.net.SocketFactory;
|
import javax.net.SocketFactory;
|
||||||
|
import org.junit.After;
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
@ -58,30 +61,20 @@ import org.robolectric.annotation.Config;
|
||||||
@RunWith(AndroidJUnit4.class)
|
@RunWith(AndroidJUnit4.class)
|
||||||
public final class RtspPlaybackTest {
|
public final class RtspPlaybackTest {
|
||||||
|
|
||||||
|
private static final long DEFAULT_TIMEOUT_MS = 8000;
|
||||||
private static final String SESSION_DESCRIPTION =
|
private static final String SESSION_DESCRIPTION =
|
||||||
"v=0\r\n"
|
"v=0\r\n"
|
||||||
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
|
+ "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n"
|
||||||
+ "s=Exoplayer test\r\n"
|
+ "s=Exoplayer test\r\n"
|
||||||
+ "t=0 0\r\n";
|
+ "t=0 0\r\n";
|
||||||
|
|
||||||
private final Context applicationContext;
|
private Context applicationContext;
|
||||||
private final CapturingRenderersFactory capturingRenderersFactory;
|
private CapturingRenderersFactory capturingRenderersFactory;
|
||||||
private final Clock clock;
|
private Clock clock;
|
||||||
private final FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel;
|
|
||||||
private final RtpDataChannel.Factory rtpDataChannelFactory;
|
|
||||||
|
|
||||||
private RtpPacketStreamDump aacRtpPacketStreamDump;
|
private RtpPacketStreamDump aacRtpPacketStreamDump;
|
||||||
// ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment.
|
// ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment.
|
||||||
private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump;
|
private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump;
|
||||||
|
private RtspServer rtspServer;
|
||||||
/** Creates a new instance. */
|
|
||||||
public RtspPlaybackTest() {
|
|
||||||
applicationContext = ApplicationProvider.getApplicationContext();
|
|
||||||
capturingRenderersFactory = new CapturingRenderersFactory(applicationContext);
|
|
||||||
clock = new FakeClock(/* isAutoAdvancing= */ true);
|
|
||||||
fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
|
|
||||||
rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public ShadowMediaCodecConfig mediaCodecConfig =
|
public ShadowMediaCodecConfig mediaCodecConfig =
|
||||||
|
|
@ -89,20 +82,29 @@ public final class RtspPlaybackTest {
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
|
applicationContext = ApplicationProvider.getApplicationContext();
|
||||||
|
capturingRenderersFactory = new CapturingRenderersFactory(applicationContext);
|
||||||
|
clock = new FakeClock(/* isAutoAdvancing= */ true);
|
||||||
aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json");
|
aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json");
|
||||||
mpeg2tsRtpPacketStreamDump =
|
mpeg2tsRtpPacketStreamDump =
|
||||||
RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json");
|
RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void tearDown() {
|
||||||
|
Util.closeQuietly(rtspServer);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception {
|
public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception {
|
||||||
|
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
|
||||||
|
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
|
||||||
ResponseProvider responseProvider =
|
ResponseProvider responseProvider =
|
||||||
new ResponseProvider(
|
new ResponseProvider(
|
||||||
clock,
|
clock,
|
||||||
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
|
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
|
||||||
fakeRtpDataChannel);
|
fakeRtpDataChannel);
|
||||||
|
rtspServer = new RtspServer(responseProvider);
|
||||||
try (RtspServer rtspServer = new RtspServer(responseProvider)) {
|
|
||||||
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
|
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
|
||||||
|
|
||||||
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
|
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
|
||||||
|
|
@ -113,18 +115,17 @@ public final class RtspPlaybackTest {
|
||||||
|
|
||||||
// Only setup the supported track (aac).
|
// Only setup the supported track (aac).
|
||||||
assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump);
|
assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump);
|
||||||
DumpFileAsserts.assertOutput(
|
DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
|
||||||
applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void prepare_noSupportedTrack_throwsPreparationError() throws Exception {
|
public void prepare_noSupportedTrack_throwsPreparationError() throws Exception {
|
||||||
|
FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
|
||||||
try (RtspServer rtspServer =
|
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel;
|
||||||
|
rtspServer =
|
||||||
new RtspServer(
|
new RtspServer(
|
||||||
new ResponseProvider(
|
new ResponseProvider(
|
||||||
clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel))) {
|
clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel));
|
||||||
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
|
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
|
||||||
|
|
||||||
AtomicReference<Throwable> playbackError = new AtomicReference<>();
|
AtomicReference<Throwable> playbackError = new AtomicReference<>();
|
||||||
|
|
@ -139,11 +140,104 @@ public final class RtspPlaybackTest {
|
||||||
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
|
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
|
||||||
player.release();
|
player.release();
|
||||||
|
|
||||||
|
assertThat(playbackError.get()).hasCauseThat().hasMessageThat().contains("No playable track.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void prepare_withUdpUnsupportedWithFallback_fallsbackToTcpAndPlaysUntilEnd()
|
||||||
|
throws Exception {
|
||||||
|
FakeTcpDataSourceRtpDataChannel fakeTcpRtpDataChannel = new FakeTcpDataSourceRtpDataChannel();
|
||||||
|
RtpDataChannel.Factory rtpTcpDataChannelFactory = (trackId) -> fakeTcpRtpDataChannel;
|
||||||
|
ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp =
|
||||||
|
new ResponseProviderSupportingOnlyTcp(
|
||||||
|
clock,
|
||||||
|
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
|
||||||
|
fakeTcpRtpDataChannel);
|
||||||
|
ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory =
|
||||||
|
new ForwardingRtpDataChannelFactory(
|
||||||
|
new UdpDataSourceRtpDataChannelFactory(DEFAULT_TIMEOUT_MS), rtpTcpDataChannelFactory);
|
||||||
|
rtspServer = new RtspServer(responseProviderSupportingOnlyTcp);
|
||||||
|
ExoPlayer player =
|
||||||
|
createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory);
|
||||||
|
|
||||||
|
PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory);
|
||||||
|
player.prepare();
|
||||||
|
player.play();
|
||||||
|
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
// Only setup the supported track (aac).
|
||||||
|
assertThat(responseProviderSupportingOnlyTcp.getDumpsForSetUpTracks())
|
||||||
|
.containsExactly(aacRtpPacketStreamDump);
|
||||||
|
DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void prepare_withUdpUnsupportedWithoutFallback_throwsRtspPlaybackException()
|
||||||
|
throws Exception {
|
||||||
|
FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
|
||||||
|
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel;
|
||||||
|
ResponseProviderSupportingOnlyTcp responseProvider =
|
||||||
|
new ResponseProviderSupportingOnlyTcp(
|
||||||
|
clock,
|
||||||
|
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
|
||||||
|
fakeUdpRtpDataChannel);
|
||||||
|
rtspServer = new RtspServer(responseProvider);
|
||||||
|
ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory);
|
||||||
|
|
||||||
|
AtomicReference<PlaybackException> playbackError = new AtomicReference<>();
|
||||||
|
player.prepare();
|
||||||
|
player.addListener(
|
||||||
|
new Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPlayerError(PlaybackException error) {
|
||||||
|
playbackError.set(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
assertThat(playbackError.get())
|
||||||
|
.hasCauseThat()
|
||||||
|
.isInstanceOf(RtspMediaSource.RtspPlaybackException.class);
|
||||||
assertThat(playbackError.get())
|
assertThat(playbackError.get())
|
||||||
.hasCauseThat()
|
.hasCauseThat()
|
||||||
.hasMessageThat()
|
.hasMessageThat()
|
||||||
.contains("No playable track.");
|
.contains("No fallback data channel factory for TCP retry");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void prepare_withUdpUnsupportedWithUdpFallback_throwsRtspUdpUnsupportedTransportException()
|
||||||
|
throws Exception {
|
||||||
|
FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel();
|
||||||
|
RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel;
|
||||||
|
ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp =
|
||||||
|
new ResponseProviderSupportingOnlyTcp(
|
||||||
|
clock,
|
||||||
|
ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump),
|
||||||
|
fakeUdpRtpDataChannel);
|
||||||
|
ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory =
|
||||||
|
new ForwardingRtpDataChannelFactory(rtpDataChannelFactory, rtpDataChannelFactory);
|
||||||
|
rtspServer = new RtspServer(responseProviderSupportingOnlyTcp);
|
||||||
|
ExoPlayer player =
|
||||||
|
createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory);
|
||||||
|
|
||||||
|
AtomicReference<PlaybackException> playbackError = new AtomicReference<>();
|
||||||
|
player.prepare();
|
||||||
|
player.addListener(
|
||||||
|
new Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPlayerError(PlaybackException error) {
|
||||||
|
playbackError.set(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null);
|
||||||
|
player.release();
|
||||||
|
|
||||||
|
assertThat(playbackError.get())
|
||||||
|
.hasCauseThat()
|
||||||
|
.isInstanceOf(RtspMediaSource.RtspUdpUnsupportedTransportException.class);
|
||||||
|
assertThat(playbackError.get()).hasCauseThat().hasMessageThat().isEqualTo("SETUP 461");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ExoPlayer createExoPlayer(
|
private ExoPlayer createExoPlayer(
|
||||||
|
|
@ -163,16 +257,16 @@ public final class RtspPlaybackTest {
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class ResponseProvider implements RtspServer.ResponseProvider {
|
private static class ResponseProvider implements RtspServer.ResponseProvider {
|
||||||
|
|
||||||
private static final String SESSION_ID = "00000000";
|
protected static final String SESSION_ID = "00000000";
|
||||||
|
|
||||||
private final Clock clock;
|
protected final Clock clock;
|
||||||
private final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks;
|
protected final ArrayList<RtpPacketStreamDump> dumpsForSetUpTracks;
|
||||||
private final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps;
|
protected final ImmutableList<RtpPacketStreamDump> rtpPacketStreamDumps;
|
||||||
private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener;
|
private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener;
|
||||||
|
|
||||||
private RtpPacketTransmitter packetTransmitter;
|
protected RtpPacketTransmitter packetTransmitter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance.
|
* Creates a new instance.
|
||||||
|
|
@ -240,22 +334,54 @@ public final class RtspPlaybackTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class FakeUdpDataSourceRtpDataChannel extends BaseDataSource
|
private static final class ResponseProviderSupportingOnlyTcp extends ResponseProvider {
|
||||||
implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener {
|
|
||||||
|
|
||||||
private static final int LOCAL_PORT = 40000;
|
/**
|
||||||
|
* Creates a new instance.
|
||||||
|
*
|
||||||
|
* @param clock The {@link Clock} used in the test.
|
||||||
|
* @param rtpPacketStreamDumps A list of {@link RtpPacketStreamDump}.
|
||||||
|
* @param binaryDataListener A {@link RtspMessageChannel.InterleavedBinaryDataListener} to send
|
||||||
|
* RTP data.
|
||||||
|
*/
|
||||||
|
public ResponseProviderSupportingOnlyTcp(
|
||||||
|
Clock clock,
|
||||||
|
List<RtpPacketStreamDump> rtpPacketStreamDumps,
|
||||||
|
RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener) {
|
||||||
|
super(clock, rtpPacketStreamDumps, binaryDataListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RtspResponse getSetupResponse(Uri requestedUri, RtspHeaders headers) {
|
||||||
|
String transportHeaderValue = checkNotNull(headers.get(RtspHeaders.TRANSPORT));
|
||||||
|
if (!transportHeaderValue.contains("TCP")) {
|
||||||
|
return new RtspResponse(
|
||||||
|
/* status= */ 461, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build());
|
||||||
|
}
|
||||||
|
for (RtpPacketStreamDump rtpPacketStreamDump : rtpPacketStreamDumps) {
|
||||||
|
if (requestedUri.toString().contains(rtpPacketStreamDump.trackName)) {
|
||||||
|
dumpsForSetUpTracks.add(rtpPacketStreamDump);
|
||||||
|
packetTransmitter = new RtpPacketTransmitter(rtpPacketStreamDump, clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RtspResponse(
|
||||||
|
/* status= */ 200, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract static class FakeBaseDataSourceRtpDataChannel extends BaseDataSource
|
||||||
|
implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener {
|
||||||
|
protected static final int LOCAL_PORT = 40000;
|
||||||
|
|
||||||
private final ConcurrentLinkedQueue<byte[]> packetQueue;
|
private final ConcurrentLinkedQueue<byte[]> packetQueue;
|
||||||
|
|
||||||
public FakeUdpDataSourceRtpDataChannel() {
|
public FakeBaseDataSourceRtpDataChannel() {
|
||||||
super(/* isNetwork= */ false);
|
super(/* isNetwork= */ false);
|
||||||
packetQueue = new ConcurrentLinkedQueue<>();
|
packetQueue = new ConcurrentLinkedQueue<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getTransport() {
|
public abstract String getTransport();
|
||||||
return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getLocalPort() {
|
public int getLocalPort() {
|
||||||
|
|
@ -307,4 +433,49 @@ public final class RtspPlaybackTest {
|
||||||
return byteToRead;
|
return byteToRead;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class FakeUdpDataSourceRtpDataChannel
|
||||||
|
extends FakeBaseDataSourceRtpDataChannel {
|
||||||
|
@Override
|
||||||
|
public String getTransport() {
|
||||||
|
return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RtspMessageChannel.InterleavedBinaryDataListener getInterleavedBinaryDataListener() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeTcpDataSourceRtpDataChannel
|
||||||
|
extends FakeBaseDataSourceRtpDataChannel {
|
||||||
|
@Override
|
||||||
|
public String getTransport() {
|
||||||
|
return Util.formatInvariant(
|
||||||
|
"RTP/AVP/TCP;unicast;interleaved=%d-%d", LOCAL_PORT + 2, LOCAL_PORT + 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ForwardingRtpDataChannelFactory implements RtpDataChannel.Factory {
|
||||||
|
|
||||||
|
private final RtpDataChannel.Factory rtpChannelFactory;
|
||||||
|
private final RtpDataChannel.Factory rtpFallbackChannelFactory;
|
||||||
|
|
||||||
|
public ForwardingRtpDataChannelFactory(
|
||||||
|
RtpDataChannel.Factory rtpChannelFactory,
|
||||||
|
RtpDataChannel.Factory rtpFallbackChannelFactory) {
|
||||||
|
this.rtpChannelFactory = rtpChannelFactory;
|
||||||
|
this.rtpFallbackChannelFactory = rtpFallbackChannelFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RtpDataChannel createAndOpenDataChannel(int trackId) throws IOException {
|
||||||
|
return rtpChannelFactory.createAndOpenDataChannel(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RtpDataChannel.Factory createFallbackDataChannelFactory() {
|
||||||
|
return rtpFallbackChannelFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
package androidx.media3.extractor;
|
package androidx.media3.extractor;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.media3.common.C;
|
||||||
import androidx.media3.common.Format;
|
import androidx.media3.common.Format;
|
||||||
import androidx.media3.common.ParserException;
|
import androidx.media3.common.ParserException;
|
||||||
import androidx.media3.common.util.CodecSpecificDataUtil;
|
import androidx.media3.common.util.CodecSpecificDataUtil;
|
||||||
|
|
@ -61,6 +62,9 @@ public final class HevcConfig {
|
||||||
int bufferPosition = 0;
|
int bufferPosition = 0;
|
||||||
int width = Format.NO_VALUE;
|
int width = Format.NO_VALUE;
|
||||||
int height = Format.NO_VALUE;
|
int height = Format.NO_VALUE;
|
||||||
|
@C.ColorSpace int colorSpace = Format.NO_VALUE;
|
||||||
|
@C.ColorRange int colorRange = Format.NO_VALUE;
|
||||||
|
@C.ColorTransfer int colorTransfer = Format.NO_VALUE;
|
||||||
float pixelWidthHeightRatio = 1;
|
float pixelWidthHeightRatio = 1;
|
||||||
@Nullable String codecs = null;
|
@Nullable String codecs = null;
|
||||||
for (int i = 0; i < numberOfArrays; i++) {
|
for (int i = 0; i < numberOfArrays; i++) {
|
||||||
|
|
@ -84,6 +88,9 @@ public final class HevcConfig {
|
||||||
buffer, bufferPosition, bufferPosition + nalUnitLength);
|
buffer, bufferPosition, bufferPosition + nalUnitLength);
|
||||||
width = spsData.width;
|
width = spsData.width;
|
||||||
height = spsData.height;
|
height = spsData.height;
|
||||||
|
colorSpace = spsData.colorSpace;
|
||||||
|
colorRange = spsData.colorRange;
|
||||||
|
colorTransfer = spsData.colorTransfer;
|
||||||
pixelWidthHeightRatio = spsData.pixelWidthHeightRatio;
|
pixelWidthHeightRatio = spsData.pixelWidthHeightRatio;
|
||||||
codecs =
|
codecs =
|
||||||
CodecSpecificDataUtil.buildHevcCodecString(
|
CodecSpecificDataUtil.buildHevcCodecString(
|
||||||
|
|
@ -102,7 +109,15 @@ public final class HevcConfig {
|
||||||
List<byte[]> initializationData =
|
List<byte[]> initializationData =
|
||||||
csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer);
|
csdLength == 0 ? Collections.emptyList() : Collections.singletonList(buffer);
|
||||||
return new HevcConfig(
|
return new HevcConfig(
|
||||||
initializationData, lengthSizeMinusOne + 1, width, height, pixelWidthHeightRatio, codecs);
|
initializationData,
|
||||||
|
lengthSizeMinusOne + 1,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixelWidthHeightRatio,
|
||||||
|
codecs,
|
||||||
|
colorSpace,
|
||||||
|
colorRange,
|
||||||
|
colorTransfer);
|
||||||
} catch (ArrayIndexOutOfBoundsException e) {
|
} catch (ArrayIndexOutOfBoundsException e) {
|
||||||
throw ParserException.createForMalformedContainer("Error parsing HEVC config", e);
|
throw ParserException.createForMalformedContainer("Error parsing HEVC config", e);
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +144,22 @@ public final class HevcConfig {
|
||||||
/** The pixel width to height ratio. */
|
/** The pixel width to height ratio. */
|
||||||
public final float pixelWidthHeightRatio;
|
public final float pixelWidthHeightRatio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link C.ColorSpace} of the video or {@link Format#NO_VALUE} if unknown or not applicable.
|
||||||
|
*/
|
||||||
|
public final @C.ColorSpace int colorSpace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link C.ColorRange} of the video or {@link Format#NO_VALUE} if unknown or not applicable.
|
||||||
|
*/
|
||||||
|
public final @C.ColorRange int colorRange;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link C.ColorTransfer} of the video or {@link Format#NO_VALUE} if unknown or not
|
||||||
|
* applicable.
|
||||||
|
*/
|
||||||
|
public final @C.ColorTransfer int colorTransfer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An RFC 6381 codecs string representing the video format, or {@code null} if not known.
|
* An RFC 6381 codecs string representing the video format, or {@code null} if not known.
|
||||||
*
|
*
|
||||||
|
|
@ -142,12 +173,18 @@ public final class HevcConfig {
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
float pixelWidthHeightRatio,
|
float pixelWidthHeightRatio,
|
||||||
@Nullable String codecs) {
|
@Nullable String codecs,
|
||||||
|
@C.ColorSpace int colorSpace,
|
||||||
|
@C.ColorRange int colorRange,
|
||||||
|
@C.ColorTransfer int colorTransfer) {
|
||||||
this.initializationData = initializationData;
|
this.initializationData = initializationData;
|
||||||
this.nalUnitLengthFieldLength = nalUnitLengthFieldLength;
|
this.nalUnitLengthFieldLength = nalUnitLengthFieldLength;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.pixelWidthHeightRatio = pixelWidthHeightRatio;
|
this.pixelWidthHeightRatio = pixelWidthHeightRatio;
|
||||||
this.codecs = codecs;
|
this.codecs = codecs;
|
||||||
|
this.colorSpace = colorSpace;
|
||||||
|
this.colorRange = colorRange;
|
||||||
|
this.colorTransfer = colorTransfer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import static java.lang.Math.min;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.media3.common.C;
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.ColorInfo;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
import androidx.media3.common.MimeTypes;
|
import androidx.media3.common.MimeTypes;
|
||||||
import androidx.media3.common.util.Assertions;
|
import androidx.media3.common.util.Assertions;
|
||||||
import androidx.media3.common.util.Log;
|
import androidx.media3.common.util.Log;
|
||||||
|
|
@ -110,6 +112,9 @@ public final class NalUnitUtil {
|
||||||
public final int width;
|
public final int width;
|
||||||
public final int height;
|
public final int height;
|
||||||
public final float pixelWidthHeightRatio;
|
public final float pixelWidthHeightRatio;
|
||||||
|
public final @C.ColorSpace int colorSpace;
|
||||||
|
public final @C.ColorRange int colorRange;
|
||||||
|
public final @C.ColorTransfer int colorTransfer;
|
||||||
|
|
||||||
public H265SpsData(
|
public H265SpsData(
|
||||||
int generalProfileSpace,
|
int generalProfileSpace,
|
||||||
|
|
@ -121,7 +126,10 @@ public final class NalUnitUtil {
|
||||||
int seqParameterSetId,
|
int seqParameterSetId,
|
||||||
int width,
|
int width,
|
||||||
int height,
|
int height,
|
||||||
float pixelWidthHeightRatio) {
|
float pixelWidthHeightRatio,
|
||||||
|
@C.ColorSpace int colorSpace,
|
||||||
|
@C.ColorRange int colorRange,
|
||||||
|
@C.ColorTransfer int colorTransfer) {
|
||||||
this.generalProfileSpace = generalProfileSpace;
|
this.generalProfileSpace = generalProfileSpace;
|
||||||
this.generalTierFlag = generalTierFlag;
|
this.generalTierFlag = generalTierFlag;
|
||||||
this.generalProfileIdc = generalProfileIdc;
|
this.generalProfileIdc = generalProfileIdc;
|
||||||
|
|
@ -132,6 +140,9 @@ public final class NalUnitUtil {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.pixelWidthHeightRatio = pixelWidthHeightRatio;
|
this.pixelWidthHeightRatio = pixelWidthHeightRatio;
|
||||||
|
this.colorSpace = colorSpace;
|
||||||
|
this.colorRange = colorRange;
|
||||||
|
this.colorTransfer = colorTransfer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,6 +499,10 @@ public final class NalUnitUtil {
|
||||||
public static H265SpsData parseH265SpsNalUnitPayload(
|
public static H265SpsData parseH265SpsNalUnitPayload(
|
||||||
byte[] nalData, int nalOffset, int nalLimit) {
|
byte[] nalData, int nalOffset, int nalLimit) {
|
||||||
ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, nalLimit);
|
ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, nalLimit);
|
||||||
|
// HDR related metadata.
|
||||||
|
@C.ColorSpace int colorSpace = Format.NO_VALUE;
|
||||||
|
@C.ColorRange int colorRange = Format.NO_VALUE;
|
||||||
|
@C.ColorTransfer int colorTransfer = Format.NO_VALUE;
|
||||||
data.skipBits(4); // sps_video_parameter_set_id
|
data.skipBits(4); // sps_video_parameter_set_id
|
||||||
int maxSubLayersMinus1 = data.readBits(3);
|
int maxSubLayersMinus1 = data.readBits(3);
|
||||||
data.skipBit(); // sps_temporal_id_nesting_flag
|
data.skipBit(); // sps_temporal_id_nesting_flag
|
||||||
|
|
@ -594,10 +609,17 @@ public final class NalUnitUtil {
|
||||||
data.skipBit(); // overscan_appropriate_flag
|
data.skipBit(); // overscan_appropriate_flag
|
||||||
}
|
}
|
||||||
if (data.readBit()) { // video_signal_type_present_flag
|
if (data.readBit()) { // video_signal_type_present_flag
|
||||||
data.skipBits(4); // video_format, video_full_range_flag
|
data.skipBits(3); // video_format
|
||||||
|
boolean fullRangeFlag = data.readBit(); // video_full_range_flag
|
||||||
if (data.readBit()) { // colour_description_present_flag
|
if (data.readBit()) { // colour_description_present_flag
|
||||||
// colour_primaries, transfer_characteristics, matrix_coeffs
|
int colorPrimaries = data.readBits(8); // colour_primaries
|
||||||
data.skipBits(24);
|
int transferCharacteristics = data.readBits(8); // transfer_characteristics
|
||||||
|
data.skipBits(8); // matrix_coeffs
|
||||||
|
|
||||||
|
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
|
||||||
|
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
|
||||||
|
colorTransfer =
|
||||||
|
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.readBit()) { // chroma_loc_info_present_flag
|
if (data.readBit()) { // chroma_loc_info_present_flag
|
||||||
|
|
@ -622,7 +644,10 @@ public final class NalUnitUtil {
|
||||||
seqParameterSetId,
|
seqParameterSetId,
|
||||||
frameWidth,
|
frameWidth,
|
||||||
frameHeight,
|
frameHeight,
|
||||||
pixelWidthHeightRatio);
|
pixelWidthHeightRatio,
|
||||||
|
colorSpace,
|
||||||
|
colorRange,
|
||||||
|
colorTransfer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,9 @@ import java.util.List;
|
||||||
@SuppressWarnings("ConstantCaseForConstants")
|
@SuppressWarnings("ConstantCaseForConstants")
|
||||||
public static final int TYPE_ddts = 0x64647473;
|
public static final int TYPE_ddts = 0x64647473;
|
||||||
|
|
||||||
|
@SuppressWarnings("ConstantCaseForConstants")
|
||||||
|
public static final int TYPE_udts = 0x75647473;
|
||||||
|
|
||||||
@SuppressWarnings("ConstantCaseForConstants")
|
@SuppressWarnings("ConstantCaseForConstants")
|
||||||
public static final int TYPE_tfdt = 0x74666474;
|
public static final int TYPE_tfdt = 0x74666474;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1164,6 +1164,9 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio;
|
pixelWidthHeightRatio = hevcConfig.pixelWidthHeightRatio;
|
||||||
}
|
}
|
||||||
codecs = hevcConfig.codecs;
|
codecs = hevcConfig.codecs;
|
||||||
|
colorSpace = hevcConfig.colorSpace;
|
||||||
|
colorRange = hevcConfig.colorRange;
|
||||||
|
colorTransfer = hevcConfig.colorTransfer;
|
||||||
} else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) {
|
} else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) {
|
||||||
@Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent);
|
@Nullable DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent);
|
||||||
if (dolbyVisionConfig != null) {
|
if (dolbyVisionConfig != null) {
|
||||||
|
|
@ -1173,6 +1176,16 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
} else if (childAtomType == Atom.TYPE_vpcC) {
|
} else if (childAtomType == Atom.TYPE_vpcC) {
|
||||||
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
|
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
|
||||||
mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9;
|
mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9;
|
||||||
|
parent.setPosition(childStartPosition + Atom.FULL_HEADER_SIZE);
|
||||||
|
// See vpcC atom syntax: https://www.webmproject.org/vp9/mp4/#syntax_1
|
||||||
|
parent.skipBytes(2); // profile(8), level(8)
|
||||||
|
boolean fullRangeFlag = (parent.readUnsignedByte() & 1) != 0;
|
||||||
|
int colorPrimaries = parent.readUnsignedByte();
|
||||||
|
int transferCharacteristics = parent.readUnsignedByte();
|
||||||
|
colorSpace = ColorInfo.isoColorPrimariesToColorSpace(colorPrimaries);
|
||||||
|
colorRange = fullRangeFlag ? C.COLOR_RANGE_FULL : C.COLOR_RANGE_LIMITED;
|
||||||
|
colorTransfer =
|
||||||
|
ColorInfo.isoTransferCharacteristicsToColorTransfer(transferCharacteristics);
|
||||||
} else if (childAtomType == Atom.TYPE_av1C) {
|
} else if (childAtomType == Atom.TYPE_av1C) {
|
||||||
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
|
ExtractorUtil.checkContainerInput(mimeType == null, /* message= */ null);
|
||||||
mimeType = MimeTypes.VIDEO_AV1;
|
mimeType = MimeTypes.VIDEO_AV1;
|
||||||
|
|
@ -1252,6 +1265,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (childAtomType == Atom.TYPE_colr) {
|
} else if (childAtomType == Atom.TYPE_colr) {
|
||||||
|
// Only modify these values if they have not been previously established by the bitstream.
|
||||||
|
// If 'Atom.TYPE_hvcC' atom or 'Atom.TYPE_vpcC' is available, they will take precedence and
|
||||||
|
// overwrite any existing values.
|
||||||
|
if (colorSpace == Format.NO_VALUE
|
||||||
|
&& colorRange == Format.NO_VALUE
|
||||||
|
&& colorTransfer == Format.NO_VALUE) {
|
||||||
int colorType = parent.readInt();
|
int colorType = parent.readInt();
|
||||||
if (colorType == TYPE_nclx || colorType == TYPE_nclc) {
|
if (colorType == TYPE_nclx || colorType == TYPE_nclc) {
|
||||||
// For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
|
// For more info on syntax, see Section 8.5.2.2 in ISO/IEC 14496-12:2012(E) and
|
||||||
|
|
@ -1274,6 +1293,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
|
Log.w(TAG, "Unsupported color type: " + Atom.getAtomTypeString(colorType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
childPosition += childAtomSize;
|
childPosition += childAtomSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1560,7 +1580,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
// because these streams can carry simultaneously multiple representations of the same
|
// because these streams can carry simultaneously multiple representations of the same
|
||||||
// audio. Use stereo by default.
|
// audio. Use stereo by default.
|
||||||
channelCount = 2;
|
channelCount = 2;
|
||||||
} else if (childAtomType == Atom.TYPE_ddts) {
|
} else if (childAtomType == Atom.TYPE_ddts || childAtomType == Atom.TYPE_udts) {
|
||||||
out.format =
|
out.format =
|
||||||
new Format.Builder()
|
new Format.Builder()
|
||||||
.setId(trackId)
|
.setId(trackId)
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,9 @@ public final class NalUnitUtilTest {
|
||||||
assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1);
|
assertThat(spsData.pixelWidthHeightRatio).isEqualTo(1);
|
||||||
assertThat(spsData.seqParameterSetId).isEqualTo(0);
|
assertThat(spsData.seqParameterSetId).isEqualTo(0);
|
||||||
assertThat(spsData.width).isEqualTo(3840);
|
assertThat(spsData.width).isEqualTo(3840);
|
||||||
|
assertThat(spsData.colorSpace).isEqualTo(6);
|
||||||
|
assertThat(spsData.colorRange).isEqualTo(2);
|
||||||
|
assertThat(spsData.colorTransfer).isEqualTo(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] buildTestData() {
|
private static byte[] buildTestData() {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ dependencies {
|
||||||
androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||||
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
|
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
|
||||||
testImplementation project(modulePrefix + 'test-utils')
|
testImplementation project(modulePrefix + 'test-utils')
|
||||||
|
testImplementation project(modulePrefix + 'test-utils-robolectric')
|
||||||
|
testImplementation project(modulePrefix + 'lib-exoplayer')
|
||||||
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ import androidx.media3.common.util.Util;
|
||||||
int controllerInterfaceVersion =
|
int controllerInterfaceVersion =
|
||||||
bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0);
|
bundle.getInt(FIELD_CONTROLLER_INTERFACE_VERSION, /* defaultValue= */ 0);
|
||||||
String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME));
|
String packageName = checkNotNull(bundle.getString(FIELD_PACKAGE_NAME));
|
||||||
int pid = bundle.getInt(FIELD_PID, /* defaultValue= */ 0);
|
checkArgument(bundle.containsKey(FIELD_PID));
|
||||||
checkArgument(pid != 0);
|
int pid = bundle.getInt(FIELD_PID);
|
||||||
@Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS);
|
@Nullable Bundle connectionHints = bundle.getBundle(FIELD_CONNECTION_HINTS);
|
||||||
return new ConnectionRequest(
|
return new ConnectionRequest(
|
||||||
libraryVersion,
|
libraryVersion,
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ import android.app.NotificationManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
import androidx.annotation.DoNotInline;
|
import androidx.annotation.DoNotInline;
|
||||||
import androidx.annotation.DrawableRes;
|
import androidx.annotation.DrawableRes;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
@ -245,7 +243,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||||
private final String channelId;
|
private final String channelId;
|
||||||
@StringRes private final int channelNameResourceId;
|
@StringRes private final int channelNameResourceId;
|
||||||
private final NotificationManager notificationManager;
|
private final NotificationManager notificationManager;
|
||||||
private final Handler mainHandler;
|
|
||||||
|
|
||||||
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
|
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
|
||||||
@DrawableRes private int smallIconResourceId;
|
@DrawableRes private int smallIconResourceId;
|
||||||
|
|
@ -278,7 +275,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||||
notificationManager =
|
notificationManager =
|
||||||
checkStateNotNull(
|
checkStateNotNull(
|
||||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
|
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
|
||||||
mainHandler = new Handler(Looper.getMainLooper());
|
|
||||||
smallIconResourceId = R.drawable.media3_notification_small_icon;
|
smallIconResourceId = R.drawable.media3_notification_small_icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,7 +342,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||||
pendingOnBitmapLoadedFutureCallback,
|
pendingOnBitmapLoadedFutureCallback,
|
||||||
// This callback must be executed on the next looper iteration, after this method has
|
// This callback must be executed on the next looper iteration, after this method has
|
||||||
// returned a media notification.
|
// returned a media notification.
|
||||||
mainHandler::post);
|
mediaSession.getImpl().getApplicationHandler()::post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1795,7 +1795,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
currentTimeline =
|
currentTimeline =
|
||||||
isQueueChanged
|
isQueueChanged
|
||||||
? QueueTimeline.create(newLegacyPlayerInfo.queue)
|
? QueueTimeline.create(newLegacyPlayerInfo.queue)
|
||||||
: new QueueTimeline((QueueTimeline) oldControllerInfo.playerInfo.timeline);
|
: ((QueueTimeline) oldControllerInfo.playerInfo.timeline).copy();
|
||||||
|
|
||||||
boolean isMetadataCompatChanged =
|
boolean isMetadataCompatChanged =
|
||||||
oldLegacyPlayerInfo.mediaMetadataCompat != newLegacyPlayerInfo.mediaMetadataCompat
|
oldLegacyPlayerInfo.mediaMetadataCompat != newLegacyPlayerInfo.mediaMetadataCompat
|
||||||
|
|
@ -1987,8 +1987,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
Integer mediaItemTransitionReason;
|
Integer mediaItemTransitionReason;
|
||||||
boolean isOldTimelineEmpty = oldControllerInfo.playerInfo.timeline.isEmpty();
|
boolean isOldTimelineEmpty = oldControllerInfo.playerInfo.timeline.isEmpty();
|
||||||
boolean isNewTimelineEmpty = newControllerInfo.playerInfo.timeline.isEmpty();
|
boolean isNewTimelineEmpty = newControllerInfo.playerInfo.timeline.isEmpty();
|
||||||
int newCurrentMediaItemIndex =
|
|
||||||
newControllerInfo.playerInfo.sessionPositionInfo.positionInfo.mediaItemIndex;
|
|
||||||
if (isOldTimelineEmpty && isNewTimelineEmpty) {
|
if (isOldTimelineEmpty && isNewTimelineEmpty) {
|
||||||
// Still empty Timelines.
|
// Still empty Timelines.
|
||||||
discontinuityReason = null;
|
discontinuityReason = null;
|
||||||
|
|
@ -2000,13 +1998,13 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
} else {
|
} else {
|
||||||
MediaItem oldCurrentMediaItem =
|
MediaItem oldCurrentMediaItem =
|
||||||
checkStateNotNull(oldControllerInfo.playerInfo.getCurrentMediaItem());
|
checkStateNotNull(oldControllerInfo.playerInfo.getCurrentMediaItem());
|
||||||
int oldCurrentMediaItemIndexInNewTimeline =
|
boolean oldCurrentMediaItemExistsInNewTimeline =
|
||||||
((QueueTimeline) newControllerInfo.playerInfo.timeline).indexOf(oldCurrentMediaItem);
|
((QueueTimeline) newControllerInfo.playerInfo.timeline).contains(oldCurrentMediaItem);
|
||||||
if (oldCurrentMediaItemIndexInNewTimeline == C.INDEX_UNSET) {
|
if (!oldCurrentMediaItemExistsInNewTimeline) {
|
||||||
// Old current item is removed.
|
// Old current item is removed.
|
||||||
discontinuityReason = Player.DISCONTINUITY_REASON_REMOVE;
|
discontinuityReason = Player.DISCONTINUITY_REASON_REMOVE;
|
||||||
mediaItemTransitionReason = Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
|
mediaItemTransitionReason = Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED;
|
||||||
} else if (oldCurrentMediaItemIndexInNewTimeline == newCurrentMediaItemIndex) {
|
} else if (oldCurrentMediaItem.equals(newControllerInfo.playerInfo.getCurrentMediaItem())) {
|
||||||
// Current item is the same.
|
// Current item is the same.
|
||||||
long oldCurrentPosition =
|
long oldCurrentPosition =
|
||||||
MediaUtils.convertToCurrentPositionMs(
|
MediaUtils.convertToCurrentPositionMs(
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ public final class MediaNotification {
|
||||||
/**
|
/**
|
||||||
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
|
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
|
||||||
* intents} for notifications.
|
* intents} for notifications.
|
||||||
|
*
|
||||||
|
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||||
|
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||||
|
* for.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public interface ActionFactory {
|
public interface ActionFactory {
|
||||||
|
|
@ -109,10 +113,20 @@ public final class MediaNotification {
|
||||||
*
|
*
|
||||||
* <p>The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat
|
* <p>The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat
|
||||||
* notification channel}, which is required to show notification for {@code SDK_INT >= 26}.
|
* notification channel}, which is required to show notification for {@code SDK_INT >= 26}.
|
||||||
|
*
|
||||||
|
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||||
|
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||||
|
* for.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public interface Provider {
|
public interface Provider {
|
||||||
/** Receives updates for a notification. */
|
/**
|
||||||
|
* Receives updates for a notification.
|
||||||
|
*
|
||||||
|
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||||
|
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||||
|
* for.
|
||||||
|
*/
|
||||||
interface Callback {
|
interface Callback {
|
||||||
/**
|
/**
|
||||||
* Called when a {@link MediaNotification} is changed.
|
* Called when a {@link MediaNotification} is changed.
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ import java.util.concurrent.TimeoutException;
|
||||||
/**
|
/**
|
||||||
* Manages media notifications for a {@link MediaSessionService} and sets the service as
|
* Manages media notifications for a {@link MediaSessionService} and sets the service as
|
||||||
* foreground/background according to the player state.
|
* foreground/background according to the player state.
|
||||||
|
*
|
||||||
|
* <p>All methods must be called on the main thread.
|
||||||
*/
|
*/
|
||||||
/* package */ final class MediaNotificationManager {
|
/* package */ final class MediaNotificationManager {
|
||||||
|
|
||||||
|
|
@ -96,11 +98,12 @@ import java.util.concurrent.TimeoutException;
|
||||||
.setListener(listener)
|
.setListener(listener)
|
||||||
.setApplicationLooper(Looper.getMainLooper())
|
.setApplicationLooper(Looper.getMainLooper())
|
||||||
.buildAsync();
|
.buildAsync();
|
||||||
|
controllerMap.put(session, controllerFuture);
|
||||||
controllerFuture.addListener(
|
controllerFuture.addListener(
|
||||||
() -> {
|
() -> {
|
||||||
try {
|
try {
|
||||||
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||||
listener.onConnected();
|
listener.onConnected(shouldShowNotification(session));
|
||||||
controller.addListener(listener);
|
controller.addListener(listener);
|
||||||
} catch (CancellationException
|
} catch (CancellationException
|
||||||
| ExecutionException
|
| ExecutionException
|
||||||
|
|
@ -111,7 +114,6 @@ import java.util.concurrent.TimeoutException;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mainExecutor);
|
mainExecutor);
|
||||||
controllerMap.put(session, controllerFuture);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeSession(MediaSession session) {
|
public void removeSession(MediaSession session) {
|
||||||
|
|
@ -123,46 +125,19 @@ import java.util.concurrent.TimeoutException;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onCustomAction(MediaSession session, String action, Bundle extras) {
|
public void onCustomAction(MediaSession session, String action, Bundle extras) {
|
||||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
@Nullable MediaController mediaController = getConnectedControllerForSession(session);
|
||||||
if (controllerFuture == null) {
|
if (mediaController == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
// Let the notification provider handle the command first before forwarding it directly.
|
||||||
MediaController mediaController = checkStateNotNull(Futures.getDone(controllerFuture));
|
Util.postOrRun(
|
||||||
|
new Handler(session.getPlayer().getApplicationLooper()),
|
||||||
|
() -> {
|
||||||
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
|
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
|
||||||
@Nullable SessionCommand customCommand = null;
|
mainExecutor.execute(
|
||||||
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
|
() -> sendCustomCommandIfCommandIsAvailable(mediaController, action));
|
||||||
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
|
|
||||||
&& command.customAction.equals(action)) {
|
|
||||||
customCommand = command;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (customCommand != null
|
|
||||||
&& mediaController.getAvailableSessionCommands().contains(customCommand)) {
|
|
||||||
ListenableFuture<SessionResult> future =
|
|
||||||
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY);
|
|
||||||
Futures.addCallback(
|
|
||||||
future,
|
|
||||||
new FutureCallback<SessionResult>() {
|
|
||||||
@Override
|
|
||||||
public void onSuccess(SessionResult result) {
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onFailure(Throwable t) {
|
|
||||||
Log.w(
|
|
||||||
TAG, "custom command " + action + " produced an error: " + t.getMessage(), t);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MoreExecutors.directExecutor());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (ExecutionException e) {
|
|
||||||
// We should never reach this.
|
|
||||||
throw new IllegalStateException(e);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -178,27 +153,42 @@ import java.util.concurrent.TimeoutException;
|
||||||
}
|
}
|
||||||
|
|
||||||
int notificationSequence = ++totalNotificationCount;
|
int notificationSequence = ++totalNotificationCount;
|
||||||
|
ImmutableList<CommandButton> customLayout = checkStateNotNull(customLayoutMap.get(session));
|
||||||
MediaNotification.Provider.Callback callback =
|
MediaNotification.Provider.Callback callback =
|
||||||
notification ->
|
notification ->
|
||||||
mainExecutor.execute(
|
mainExecutor.execute(
|
||||||
() -> onNotificationUpdated(notificationSequence, session, notification));
|
() -> onNotificationUpdated(notificationSequence, session, notification));
|
||||||
|
Util.postOrRun(
|
||||||
|
new Handler(session.getPlayer().getApplicationLooper()),
|
||||||
|
() -> {
|
||||||
MediaNotification mediaNotification =
|
MediaNotification mediaNotification =
|
||||||
this.mediaNotificationProvider.createNotification(
|
this.mediaNotificationProvider.createNotification(
|
||||||
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback);
|
session, customLayout, actionFactory, callback);
|
||||||
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
|
mainExecutor.execute(
|
||||||
|
() ->
|
||||||
|
updateNotificationInternal(
|
||||||
|
session, mediaNotification, startInForegroundRequired));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isStartedInForeground() {
|
public boolean isStartedInForeground() {
|
||||||
return startedInForeground;
|
return startedInForeground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* package */ boolean shouldRunInForeground(
|
||||||
|
MediaSession session, boolean startInForegroundWhenPaused) {
|
||||||
|
@Nullable MediaController controller = getConnectedControllerForSession(session);
|
||||||
|
return controller != null
|
||||||
|
&& (controller.getPlayWhenReady() || startInForegroundWhenPaused)
|
||||||
|
&& (controller.getPlaybackState() == Player.STATE_READY
|
||||||
|
|| controller.getPlaybackState() == Player.STATE_BUFFERING);
|
||||||
|
}
|
||||||
|
|
||||||
private void onNotificationUpdated(
|
private void onNotificationUpdated(
|
||||||
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
|
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
|
||||||
if (notificationSequence == totalNotificationCount) {
|
if (notificationSequence == totalNotificationCount) {
|
||||||
boolean startInForegroundRequired =
|
boolean startInForegroundRequired =
|
||||||
MediaSessionService.shouldRunInForeground(
|
shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
|
||||||
session, /* startInForegroundWhenPaused= */ false);
|
|
||||||
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
|
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,8 +226,7 @@ import java.util.concurrent.TimeoutException;
|
||||||
private void maybeStopForegroundService(boolean removeNotifications) {
|
private void maybeStopForegroundService(boolean removeNotifications) {
|
||||||
List<MediaSession> sessions = mediaSessionService.getSessions();
|
List<MediaSession> sessions = mediaSessionService.getSessions();
|
||||||
for (int i = 0; i < sessions.size(); i++) {
|
for (int i = 0; i < sessions.size(); i++) {
|
||||||
if (MediaSessionService.shouldRunInForeground(
|
if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
|
||||||
sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,9 +240,56 @@ import java.util.concurrent.TimeoutException;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean shouldShowNotification(MediaSession session) {
|
private boolean shouldShowNotification(MediaSession session) {
|
||||||
Player player = session.getPlayer();
|
MediaController controller = getConnectedControllerForSession(session);
|
||||||
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE;
|
return controller != null
|
||||||
|
&& !controller.getCurrentTimeline().isEmpty()
|
||||||
|
&& controller.getPlaybackState() != Player.STATE_IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private MediaController getConnectedControllerForSession(MediaSession session) {
|
||||||
|
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||||
|
if (controllerFuture == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Futures.getDone(controllerFuture);
|
||||||
|
} catch (ExecutionException exception) {
|
||||||
|
// We should never reach this.
|
||||||
|
throw new IllegalStateException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendCustomCommandIfCommandIsAvailable(
|
||||||
|
MediaController mediaController, String action) {
|
||||||
|
@Nullable SessionCommand customCommand = null;
|
||||||
|
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
|
||||||
|
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
|
||||||
|
&& command.customAction.equals(action)) {
|
||||||
|
customCommand = command;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (customCommand != null
|
||||||
|
&& mediaController.getAvailableSessionCommands().contains(customCommand)) {
|
||||||
|
ListenableFuture<SessionResult> future =
|
||||||
|
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY);
|
||||||
|
Futures.addCallback(
|
||||||
|
future,
|
||||||
|
new FutureCallback<SessionResult>() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(SessionResult result) {
|
||||||
|
// Do nothing.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable t) {
|
||||||
|
Log.w(TAG, "custom command " + action + " produced an error: " + t.getMessage(), t);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class MediaControllerListener
|
private static final class MediaControllerListener
|
||||||
|
|
@ -271,8 +307,8 @@ import java.util.concurrent.TimeoutException;
|
||||||
this.customLayoutMap = customLayoutMap;
|
this.customLayoutMap = customLayoutMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onConnected() {
|
public void onConnected(boolean shouldShowNotification) {
|
||||||
if (shouldShowNotification(session)) {
|
if (shouldShowNotification) {
|
||||||
mediaSessionService.onUpdateNotificationInternal(
|
mediaSessionService.onUpdateNotificationInternal(
|
||||||
session, /* startInForegroundWhenPaused= */ false);
|
session, /* startInForegroundWhenPaused= */ false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,9 @@ public class MediaSession {
|
||||||
* </tr>
|
* </tr>
|
||||||
* </table>
|
* </table>
|
||||||
*
|
*
|
||||||
|
* <p>Interoperability: This call has no effect when called for a {@linkplain
|
||||||
|
* ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}.
|
||||||
|
*
|
||||||
* @param controller The controller to specify layout.
|
* @param controller The controller to specify layout.
|
||||||
* @param layout The ordered list of {@link CommandButton}.
|
* @param layout The ordered list of {@link CommandButton}.
|
||||||
*/
|
*/
|
||||||
|
|
@ -793,6 +796,9 @@ public class MediaSession {
|
||||||
*
|
*
|
||||||
* <p>This is a synchronous call and doesn't wait for results from the controller.
|
* <p>This is a synchronous call and doesn't wait for results from the controller.
|
||||||
*
|
*
|
||||||
|
* <p>Interoperability: This call has no effect when called for a {@linkplain
|
||||||
|
* ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}.
|
||||||
|
*
|
||||||
* @param controller The controller to send the extras to.
|
* @param controller The controller to send the extras to.
|
||||||
* @param sessionExtras The session extras.
|
* @param sessionExtras The session extras.
|
||||||
*/
|
*/
|
||||||
|
|
@ -816,6 +822,9 @@ public class MediaSession {
|
||||||
*
|
*
|
||||||
* <p>A command is not accepted if it is not a custom command.
|
* <p>A command is not accepted if it is not a custom command.
|
||||||
*
|
*
|
||||||
|
* <p>Interoperability: This call has no effect when called for a {@linkplain
|
||||||
|
* ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}.
|
||||||
|
*
|
||||||
* @param controller The controller to send the custom command to.
|
* @param controller The controller to send the custom command to.
|
||||||
* @param command A custom command.
|
* @param command A custom command.
|
||||||
* @param args A {@link Bundle} for additional arguments. May be empty.
|
* @param args A {@link Bundle} for additional arguments. May be empty.
|
||||||
|
|
@ -890,12 +899,20 @@ public class MediaSession {
|
||||||
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs);
|
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the {@linkplain Listener listener}. */
|
/**
|
||||||
|
* Sets the {@linkplain Listener listener}.
|
||||||
|
*
|
||||||
|
* <p>This method must be called on the main thread.
|
||||||
|
*/
|
||||||
/* package */ void setListener(Listener listener) {
|
/* package */ void setListener(Listener listener) {
|
||||||
impl.setMediaSessionListener(listener);
|
impl.setMediaSessionListener(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears the {@linkplain Listener listener}. */
|
/**
|
||||||
|
* Clears the {@linkplain Listener listener}.
|
||||||
|
*
|
||||||
|
* <p>This method must be called on the main thread.
|
||||||
|
*/
|
||||||
/* package */ void clearListener() {
|
/* package */ void clearListener() {
|
||||||
impl.clearMediaSessionListener();
|
impl.clearMediaSessionListener();
|
||||||
}
|
}
|
||||||
|
|
@ -1426,7 +1443,11 @@ public class MediaSession {
|
||||||
default void onRenderedFirstFrame(int seq) throws RemoteException {}
|
default void onRenderedFirstFrame(int seq) throws RemoteException {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Listener for media session events */
|
/**
|
||||||
|
* Listener for media session events.
|
||||||
|
*
|
||||||
|
* <p>All methods must be called on the main thread.
|
||||||
|
*/
|
||||||
/* package */ interface Listener {
|
/* package */ interface Listener {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,21 +15,17 @@
|
||||||
*/
|
*/
|
||||||
package androidx.media3.session;
|
package androidx.media3.session;
|
||||||
|
|
||||||
import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
|
|
||||||
import static androidx.media3.common.util.Assertions.checkNotNull;
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
import static androidx.media3.common.util.Util.castNonNull;
|
|
||||||
import static androidx.media3.common.util.Util.postOrRun;
|
import static androidx.media3.common.util.Util.postOrRun;
|
||||||
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
|
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
|
||||||
import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN;
|
import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN;
|
||||||
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
|
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.content.pm.ResolveInfo;
|
import android.content.pm.ResolveInfo;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
|
@ -43,7 +39,6 @@ import android.os.Process;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.os.SystemClock;
|
import android.os.SystemClock;
|
||||||
import android.support.v4.media.session.MediaSessionCompat;
|
import android.support.v4.media.session.MediaSessionCompat;
|
||||||
import android.view.KeyEvent;
|
|
||||||
import androidx.annotation.FloatRange;
|
import androidx.annotation.FloatRange;
|
||||||
import androidx.annotation.GuardedBy;
|
import androidx.annotation.GuardedBy;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
@ -66,7 +61,6 @@ import androidx.media3.common.Tracks;
|
||||||
import androidx.media3.common.VideoSize;
|
import androidx.media3.common.VideoSize;
|
||||||
import androidx.media3.common.text.CueGroup;
|
import androidx.media3.common.text.CueGroup;
|
||||||
import androidx.media3.common.util.Log;
|
import androidx.media3.common.util.Log;
|
||||||
import androidx.media3.common.util.Util;
|
|
||||||
import androidx.media3.session.MediaSession.ControllerCb;
|
import androidx.media3.session.MediaSession.ControllerCb;
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo;
|
import androidx.media3.session.MediaSession.ControllerInfo;
|
||||||
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
|
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
|
||||||
|
|
@ -74,9 +68,11 @@ import androidx.media3.session.SequencedFutureManager.SequencedFuture;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
import com.google.common.util.concurrent.SettableFuture;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
import org.checkerframework.checker.initialization.qual.Initialized;
|
import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
|
|
||||||
/* package */ class MediaSessionImpl {
|
/* package */ class MediaSessionImpl {
|
||||||
|
|
@ -115,13 +111,13 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
private final SessionToken sessionToken;
|
private final SessionToken sessionToken;
|
||||||
private final MediaSession instance;
|
private final MediaSession instance;
|
||||||
@Nullable private final PendingIntent sessionActivity;
|
@Nullable private final PendingIntent sessionActivity;
|
||||||
private final PendingIntent mediaButtonIntent;
|
|
||||||
@Nullable private final BroadcastReceiver broadcastReceiver;
|
|
||||||
private final Handler applicationHandler;
|
private final Handler applicationHandler;
|
||||||
private final BitmapLoader bitmapLoader;
|
private final BitmapLoader bitmapLoader;
|
||||||
private final Runnable periodicSessionPositionInfoUpdateRunnable;
|
private final Runnable periodicSessionPositionInfoUpdateRunnable;
|
||||||
|
private final Handler mainHandler;
|
||||||
|
|
||||||
@Nullable private PlayerListener playerListener;
|
@Nullable private PlayerListener playerListener;
|
||||||
|
|
||||||
@Nullable private MediaSession.Listener mediaSessionListener;
|
@Nullable private MediaSession.Listener mediaSessionListener;
|
||||||
|
|
||||||
private PlayerInfo playerInfo;
|
private PlayerInfo playerInfo;
|
||||||
|
|
@ -156,6 +152,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
sessionStub = new MediaSessionStub(thisRef);
|
sessionStub = new MediaSessionStub(thisRef);
|
||||||
this.sessionActivity = sessionActivity;
|
this.sessionActivity = sessionActivity;
|
||||||
|
|
||||||
|
mainHandler = new Handler(Looper.getMainLooper());
|
||||||
applicationHandler = new Handler(player.getApplicationLooper());
|
applicationHandler = new Handler(player.getApplicationLooper());
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.bitmapLoader = bitmapLoader;
|
this.bitmapLoader = bitmapLoader;
|
||||||
|
|
@ -189,52 +186,21 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
sessionStub,
|
sessionStub,
|
||||||
tokenExtras);
|
tokenExtras);
|
||||||
|
|
||||||
@Nullable ComponentName mbrComponent;
|
|
||||||
synchronized (STATIC_LOCK) {
|
synchronized (STATIC_LOCK) {
|
||||||
if (!componentNamesInitialized) {
|
if (!componentNamesInitialized) {
|
||||||
serviceComponentName =
|
MediaSessionImpl.serviceComponentName =
|
||||||
getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE);
|
getServiceComponentByAction(context, MediaLibraryService.SERVICE_INTERFACE);
|
||||||
if (serviceComponentName == null) {
|
if (MediaSessionImpl.serviceComponentName == null) {
|
||||||
serviceComponentName =
|
MediaSessionImpl.serviceComponentName =
|
||||||
getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE);
|
getServiceComponentByAction(context, MediaSessionService.SERVICE_INTERFACE);
|
||||||
}
|
}
|
||||||
componentNamesInitialized = true;
|
componentNamesInitialized = true;
|
||||||
}
|
}
|
||||||
mbrComponent = serviceComponentName;
|
|
||||||
}
|
|
||||||
int pendingIntentFlagMutable = Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0;
|
|
||||||
if (mbrComponent == null) {
|
|
||||||
// No service to revive playback after it's dead.
|
|
||||||
// Create a PendingIntent that points to the runtime broadcast receiver.
|
|
||||||
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
|
|
||||||
intent.setPackage(context.getPackageName());
|
|
||||||
mediaButtonIntent =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context, /* requestCode= */ 0, intent, pendingIntentFlagMutable);
|
|
||||||
|
|
||||||
// Creates a fake ComponentName for MediaSessionCompat in pre-L.
|
|
||||||
mbrComponent = new ComponentName(context, context.getClass());
|
|
||||||
|
|
||||||
// Create and register a BroadcastReceiver for receiving PendingIntent.
|
|
||||||
broadcastReceiver = new MediaButtonReceiver();
|
|
||||||
IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON);
|
|
||||||
filter.addDataScheme(castNonNull(sessionUri.getScheme()));
|
|
||||||
Util.registerReceiverNotExported(context, broadcastReceiver, filter);
|
|
||||||
} else {
|
|
||||||
// Has MediaSessionService to revive playback after it's dead.
|
|
||||||
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
|
|
||||||
intent.setComponent(mbrComponent);
|
|
||||||
if (Util.SDK_INT >= 26) {
|
|
||||||
mediaButtonIntent =
|
|
||||||
PendingIntent.getForegroundService(context, 0, intent, pendingIntentFlagMutable);
|
|
||||||
} else {
|
|
||||||
mediaButtonIntent = PendingIntent.getService(context, 0, intent, pendingIntentFlagMutable);
|
|
||||||
}
|
|
||||||
broadcastReceiver = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionLegacyStub =
|
sessionLegacyStub =
|
||||||
new MediaSessionLegacyStub(thisRef, mbrComponent, mediaButtonIntent, applicationHandler);
|
new MediaSessionLegacyStub(
|
||||||
|
/* session= */ thisRef, sessionUri, serviceComponentName, applicationHandler);
|
||||||
|
|
||||||
PlayerWrapper playerWrapper = new PlayerWrapper(player);
|
PlayerWrapper playerWrapper = new PlayerWrapper(player);
|
||||||
this.playerWrapper = playerWrapper;
|
this.playerWrapper = playerWrapper;
|
||||||
|
|
@ -278,8 +244,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
playerInfo = newPlayerWrapper.createPlayerInfoForBundling();
|
playerInfo = newPlayerWrapper.createPlayerInfoForBundling();
|
||||||
onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
|
handleAvailablePlayerCommandsChanged(newPlayerWrapper.getAvailableCommands());
|
||||||
/* excludeTimeline= */ false, /* excludeTracks= */ false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release() {
|
public void release() {
|
||||||
|
|
@ -305,10 +270,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
Log.w(TAG, "Exception thrown while closing", e);
|
Log.w(TAG, "Exception thrown while closing", e);
|
||||||
}
|
}
|
||||||
sessionLegacyStub.release();
|
sessionLegacyStub.release();
|
||||||
mediaButtonIntent.cancel();
|
|
||||||
if (broadcastReceiver != null) {
|
|
||||||
context.unregisterReceiver(broadcastReceiver);
|
|
||||||
}
|
|
||||||
sessionStub.release();
|
sessionStub.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,7 +356,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
|
|
||||||
private void dispatchOnPlayerInfoChanged(
|
private void dispatchOnPlayerInfoChanged(
|
||||||
PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) {
|
PlayerInfo playerInfo, boolean excludeTimeline, boolean excludeTracks) {
|
||||||
|
playerInfo = sessionStub.generateAndCacheUniqueTrackGroupIds(playerInfo);
|
||||||
List<ControllerInfo> controllers =
|
List<ControllerInfo> controllers =
|
||||||
sessionStub.getConnectedControllersManager().getConnectedControllers();
|
sessionStub.getConnectedControllersManager().getConnectedControllers();
|
||||||
for (int i = 0; i < controllers.size(); i++) {
|
for (int i = 0; i < controllers.size(); i++) {
|
||||||
|
|
@ -589,12 +550,25 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* package */ void onNotificationRefreshRequired() {
|
/* package */ void onNotificationRefreshRequired() {
|
||||||
|
postOrRun(
|
||||||
|
mainHandler,
|
||||||
|
() -> {
|
||||||
if (this.mediaSessionListener != null) {
|
if (this.mediaSessionListener != null) {
|
||||||
this.mediaSessionListener.onNotificationRefreshRequired(instance);
|
this.mediaSessionListener.onNotificationRefreshRequired(instance);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* package */ boolean onPlayRequested() {
|
/* package */ boolean onPlayRequested() {
|
||||||
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
|
SettableFuture<Boolean> playRequested = SettableFuture.create();
|
||||||
|
mainHandler.post(() -> playRequested.set(onPlayRequested()));
|
||||||
|
try {
|
||||||
|
return playRequested.get();
|
||||||
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.mediaSessionListener != null) {
|
if (this.mediaSessionListener != null) {
|
||||||
return this.mediaSessionListener.onPlayRequested(instance);
|
return this.mediaSessionListener.onPlayRequested(instance);
|
||||||
}
|
}
|
||||||
|
|
@ -772,6 +746,20 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleAvailablePlayerCommandsChanged(Player.Commands availableCommands) {
|
||||||
|
// Update PlayerInfo and do not force exclude elements in case they need to be updated because
|
||||||
|
// an available command has been removed.
|
||||||
|
onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
|
||||||
|
/* excludeTimeline= */ false, /* excludeTracks= */ false);
|
||||||
|
dispatchRemoteControllerTaskWithoutReturn(
|
||||||
|
(callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands));
|
||||||
|
|
||||||
|
// Forcefully update playback info to update VolumeProviderCompat in case
|
||||||
|
// COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed.
|
||||||
|
dispatchRemoteControllerTaskToLegacyStub(
|
||||||
|
(callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo));
|
||||||
|
}
|
||||||
|
|
||||||
/* @FunctionalInterface */
|
/* @FunctionalInterface */
|
||||||
interface RemoteControllerTask {
|
interface RemoteControllerTask {
|
||||||
|
|
||||||
|
|
@ -1182,16 +1170,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean excludeTracks = !availableCommands.contains(COMMAND_GET_TRACKS);
|
session.handleAvailablePlayerCommandsChanged(availableCommands);
|
||||||
session.onPlayerInfoChangedHandler.sendPlayerInfoChangedMessage(
|
|
||||||
/* excludeTimeline= */ false, excludeTracks);
|
|
||||||
session.dispatchRemoteControllerTaskWithoutReturn(
|
|
||||||
(callback, seq) -> callback.onAvailableCommandsChangedFromPlayer(seq, availableCommands));
|
|
||||||
|
|
||||||
// Forcefully update playback info to update VolumeProviderCompat in case
|
|
||||||
// COMMAND_ADJUST_DEVICE_VOLUME or COMMAND_SET_DEVICE_VOLUME value has changed.
|
|
||||||
session.dispatchRemoteControllerTaskToLegacyStub(
|
|
||||||
(callback, seq) -> callback.onDeviceInfoChanged(seq, session.playerInfo.deviceInfo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -1281,26 +1260,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver
|
|
||||||
private final class MediaButtonReceiver extends BroadcastReceiver {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Uri sessionUri = intent.getData();
|
|
||||||
if (!Util.areEqual(sessionUri, MediaSessionImpl.this.sessionUri)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyEvent keyEvent = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
|
||||||
if (keyEvent == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PlayerInfoChangedHandler extends Handler {
|
private class PlayerInfoChangedHandler extends Handler {
|
||||||
|
|
||||||
private static final int MSG_PLAYER_INFO_CHANGED = 1;
|
private static final int MSG_PLAYER_INFO_CHANGED = 1;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import static androidx.media3.common.Player.STATE_ENDED;
|
||||||
import static androidx.media3.common.Player.STATE_IDLE;
|
import static androidx.media3.common.Player.STATE_IDLE;
|
||||||
import static androidx.media3.common.util.Assertions.checkNotNull;
|
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||||
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||||
|
import static androidx.media3.common.util.Util.castNonNull;
|
||||||
import static androidx.media3.common.util.Util.postOrRun;
|
import static androidx.media3.common.util.Util.postOrRun;
|
||||||
import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
|
import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
|
||||||
import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM;
|
import static androidx.media3.session.SessionCommand.COMMAND_CODE_CUSTOM;
|
||||||
|
|
@ -43,9 +44,13 @@ import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
|
||||||
import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
|
import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.pm.ResolveInfo;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
@ -107,6 +112,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
|
|
||||||
private static final String TAG = "MediaSessionLegacyStub";
|
private static final String TAG = "MediaSessionLegacyStub";
|
||||||
|
|
||||||
|
private static final int PENDING_INTENT_FLAG_MUTABLE =
|
||||||
|
Util.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0;
|
||||||
private static final String DEFAULT_MEDIA_SESSION_TAG_PREFIX = "androidx.media3.session.id";
|
private static final String DEFAULT_MEDIA_SESSION_TAG_PREFIX = "androidx.media3.session.id";
|
||||||
private static final String DEFAULT_MEDIA_SESSION_TAG_DELIM = ".";
|
private static final String DEFAULT_MEDIA_SESSION_TAG_DELIM = ".";
|
||||||
|
|
||||||
|
|
@ -122,6 +129,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
|
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
|
||||||
private final MediaSessionCompat sessionCompat;
|
private final MediaSessionCompat sessionCompat;
|
||||||
private final String appPackageName;
|
private final String appPackageName;
|
||||||
|
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
|
||||||
|
private final boolean canResumePlaybackOnStart;
|
||||||
@Nullable private VolumeProviderCompat volumeProviderCompat;
|
@Nullable private VolumeProviderCompat volumeProviderCompat;
|
||||||
|
|
||||||
private volatile long connectionTimeoutMs;
|
private volatile long connectionTimeoutMs;
|
||||||
|
|
@ -130,8 +139,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
|
|
||||||
public MediaSessionLegacyStub(
|
public MediaSessionLegacyStub(
|
||||||
MediaSessionImpl session,
|
MediaSessionImpl session,
|
||||||
ComponentName mbrComponent,
|
Uri sessionUri,
|
||||||
PendingIntent mediaButtonIntent,
|
@Nullable ComponentName serviceComponentName,
|
||||||
Handler handler) {
|
Handler handler) {
|
||||||
sessionImpl = session;
|
sessionImpl = session;
|
||||||
Context context = sessionImpl.getContext();
|
Context context = sessionImpl.getContext();
|
||||||
|
|
@ -145,6 +154,44 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
connectedControllersManager = new ConnectedControllersManager<>(session);
|
connectedControllersManager = new ConnectedControllersManager<>(session);
|
||||||
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
|
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
|
||||||
|
|
||||||
|
// Select a media button receiver component.
|
||||||
|
ComponentName receiverComponentName = queryPackageManagerForMediaButtonReceiver(context);
|
||||||
|
// Assume an app that intentionally puts a `MediaButtonReceiver` into the manifest has
|
||||||
|
// implemented some kind of resumption of the last recently played media item.
|
||||||
|
canResumePlaybackOnStart = receiverComponentName != null;
|
||||||
|
if (receiverComponentName == null) {
|
||||||
|
receiverComponentName = serviceComponentName;
|
||||||
|
}
|
||||||
|
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON, sessionUri);
|
||||||
|
PendingIntent mediaButtonIntent;
|
||||||
|
if (receiverComponentName == null) {
|
||||||
|
// Neither a media button receiver from the app manifest nor a service available that could
|
||||||
|
// handle media button events. Create a runtime receiver and a pending intent for it.
|
||||||
|
runtimeBroadcastReceiver = new MediaButtonReceiver();
|
||||||
|
IntentFilter filter = new IntentFilter(Intent.ACTION_MEDIA_BUTTON);
|
||||||
|
filter.addDataScheme(castNonNull(sessionUri.getScheme()));
|
||||||
|
Util.registerReceiverNotExported(context, runtimeBroadcastReceiver, filter);
|
||||||
|
// Create a pending intent to be broadcast to the receiver.
|
||||||
|
intent.setPackage(context.getPackageName());
|
||||||
|
mediaButtonIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE);
|
||||||
|
// Creates a fake ComponentName for MediaSessionCompat in pre-L.
|
||||||
|
receiverComponentName = new ComponentName(context, context.getClass());
|
||||||
|
} else {
|
||||||
|
intent.setComponent(receiverComponentName);
|
||||||
|
mediaButtonIntent =
|
||||||
|
Objects.equals(serviceComponentName, receiverComponentName)
|
||||||
|
? (Util.SDK_INT >= 26
|
||||||
|
? PendingIntent.getForegroundService(
|
||||||
|
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE)
|
||||||
|
: PendingIntent.getService(
|
||||||
|
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE))
|
||||||
|
: PendingIntent.getBroadcast(
|
||||||
|
context, /* requestCode= */ 0, intent, PENDING_INTENT_FLAG_MUTABLE);
|
||||||
|
runtimeBroadcastReceiver = null;
|
||||||
|
}
|
||||||
|
|
||||||
String sessionCompatId =
|
String sessionCompatId =
|
||||||
TextUtils.join(
|
TextUtils.join(
|
||||||
DEFAULT_MEDIA_SESSION_TAG_DELIM,
|
DEFAULT_MEDIA_SESSION_TAG_DELIM,
|
||||||
|
|
@ -153,7 +200,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
new MediaSessionCompat(
|
new MediaSessionCompat(
|
||||||
context,
|
context,
|
||||||
sessionCompatId,
|
sessionCompatId,
|
||||||
mbrComponent,
|
receiverComponentName,
|
||||||
mediaButtonIntent,
|
mediaButtonIntent,
|
||||||
session.getToken().getExtras());
|
session.getToken().getExtras());
|
||||||
|
|
||||||
|
|
@ -168,12 +215,38 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
sessionCompat.setCallback(thisRef, handler);
|
sessionCompat.setCallback(thisRef, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static ComponentName queryPackageManagerForMediaButtonReceiver(Context context) {
|
||||||
|
PackageManager pm = context.getPackageManager();
|
||||||
|
Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
|
||||||
|
queryIntent.setPackage(context.getPackageName());
|
||||||
|
List<ResolveInfo> resolveInfos = pm.queryBroadcastReceivers(queryIntent, /* flags= */ 0);
|
||||||
|
if (resolveInfos.size() == 1) {
|
||||||
|
ResolveInfo resolveInfo = resolveInfos.get(0);
|
||||||
|
return new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name);
|
||||||
|
} else if (resolveInfos.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Expected 1 broadcast receiver that handles "
|
||||||
|
+ Intent.ACTION_MEDIA_BUTTON
|
||||||
|
+ ", found "
|
||||||
|
+ resolveInfos.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts to receive commands. */
|
/** Starts to receive commands. */
|
||||||
public void start() {
|
public void start() {
|
||||||
sessionCompat.setActive(true);
|
sessionCompat.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release() {
|
public void release() {
|
||||||
|
if (!canResumePlaybackOnStart) {
|
||||||
|
setMediaButtonReceiver(sessionCompat, /* mediaButtonReceiverIntent= */ null);
|
||||||
|
}
|
||||||
|
if (runtimeBroadcastReceiver != null) {
|
||||||
|
sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver);
|
||||||
|
}
|
||||||
sessionCompat.release();
|
sessionCompat.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -832,6 +905,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
sessionCompat.setMetadata(metadataCompat);
|
sessionCompat.setMetadata(metadataCompat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
|
||||||
|
private static void setMediaButtonReceiver(
|
||||||
|
MediaSessionCompat sessionCompat, @Nullable PendingIntent mediaButtonReceiverIntent) {
|
||||||
|
sessionCompat.setMediaButtonReceiver(mediaButtonReceiverIntent);
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
|
@SuppressWarnings("nullness:argument") // MediaSessionCompat didn't annotate @Nullable.
|
||||||
private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) {
|
private static void setQueue(MediaSessionCompat sessionCompat, @Nullable List<QueueItem> queue) {
|
||||||
sessionCompat.setQueue(queue);
|
sessionCompat.setQueue(queue);
|
||||||
|
|
@ -987,6 +1066,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
sessionImpl.getSessionCompat().setExtras(sessionExtras);
|
sessionImpl.getSessionCompat().setExtras(sessionExtras);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendCustomCommand(int seq, SessionCommand command, Bundle args) {
|
||||||
|
sessionImpl.getSessionCompat().sendSessionEvent(command.customAction, args);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayWhenReadyChanged(
|
public void onPlayWhenReadyChanged(
|
||||||
int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason)
|
int seq, boolean playWhenReady, @Player.PlaybackSuppressionReason int reason)
|
||||||
|
|
@ -1237,11 +1321,6 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
lastMediaMetadata = newMediaMetadata;
|
lastMediaMetadata = newMediaMetadata;
|
||||||
lastDurationMs = newDurationMs;
|
lastDurationMs = newDurationMs;
|
||||||
|
|
||||||
if (currentMediaItem == null) {
|
|
||||||
setMetadata(sessionCompat, /* metadataCompat= */ null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable Bitmap artworkBitmap = null;
|
@Nullable Bitmap artworkBitmap = null;
|
||||||
ListenableFuture<Bitmap> bitmapFuture =
|
ListenableFuture<Bitmap> bitmapFuture =
|
||||||
sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata);
|
sessionImpl.getBitmapLoader().loadBitmapFromMetadata(newMediaMetadata);
|
||||||
|
|
@ -1353,4 +1432,24 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
|
||||||
private static String getBitmapLoadErrorMessage(Throwable throwable) {
|
private static String getBitmapLoadErrorMessage(Throwable throwable) {
|
||||||
return "Failed to load bitmap: " + throwable.getMessage();
|
return "Failed to load bitmap: " + throwable.getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(b/193193462): Replace this with androidx.media.session.MediaButtonReceiver
|
||||||
|
private final class MediaButtonReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (!Util.areEqual(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri sessionUri = intent.getData();
|
||||||
|
if (!Util.areEqual(sessionUri, sessionUri)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
||||||
|
if (keyEvent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi;
|
||||||
import androidx.collection.ArrayMap;
|
import androidx.collection.ArrayMap;
|
||||||
import androidx.media.MediaBrowserServiceCompat;
|
import androidx.media.MediaBrowserServiceCompat;
|
||||||
import androidx.media.MediaSessionManager;
|
import androidx.media.MediaSessionManager;
|
||||||
import androidx.media3.common.Player;
|
|
||||||
import androidx.media3.common.util.Log;
|
import androidx.media3.common.util.Log;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
|
|
@ -183,7 +182,6 @@ public abstract class MediaSessionService extends Service {
|
||||||
@Nullable
|
@Nullable
|
||||||
private Listener listener;
|
private Listener listener;
|
||||||
|
|
||||||
@GuardedBy("lock")
|
|
||||||
private boolean defaultMethodCalled;
|
private boolean defaultMethodCalled;
|
||||||
|
|
||||||
/** Creates a service. */
|
/** Creates a service. */
|
||||||
|
|
@ -198,6 +196,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* Called when the service is created.
|
* Called when the service is created.
|
||||||
*
|
*
|
||||||
* <p>Override this method if you need your own initialization.
|
* <p>Override this method if you need your own initialization.
|
||||||
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
*/
|
*/
|
||||||
@CallSuper
|
@CallSuper
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -234,7 +234,7 @@ public abstract class MediaSessionService extends Service {
|
||||||
* <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link
|
* <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link
|
||||||
* ControllerInfo#getConnectionHints()} have no meaning.
|
* ControllerInfo#getConnectionHints()} have no meaning.
|
||||||
*
|
*
|
||||||
* <p>This method is always called on the main thread.
|
* <p>This method will be called on the main thread.
|
||||||
*
|
*
|
||||||
* @param controllerInfo The information of the controller that is trying to connect.
|
* @param controllerInfo The information of the controller that is trying to connect.
|
||||||
* @return A {@link MediaSession} for the controller, or {@code null} to reject the connection.
|
* @return A {@link MediaSession} for the controller, or {@code null} to reject the connection.
|
||||||
|
|
@ -251,6 +251,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* <p>The added session will be removed automatically {@linkplain MediaSession#release() when the
|
* <p>The added session will be removed automatically {@linkplain MediaSession#release() when the
|
||||||
* session is released}.
|
* session is released}.
|
||||||
*
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
|
*
|
||||||
* @param session A session to be added.
|
* @param session A session to be added.
|
||||||
* @see #removeSession(MediaSession)
|
* @see #removeSession(MediaSession)
|
||||||
* @see #getSessions()
|
* @see #getSessions()
|
||||||
|
|
@ -268,8 +270,12 @@ public abstract class MediaSessionService extends Service {
|
||||||
// Session has returned for the first time. Register callbacks.
|
// Session has returned for the first time. Register callbacks.
|
||||||
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
||||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||||
postOrRun(mainHandler, () -> notificationManager.addSession(session));
|
postOrRun(
|
||||||
|
mainHandler,
|
||||||
|
() -> {
|
||||||
|
notificationManager.addSession(session);
|
||||||
session.setListener(new MediaSessionListener());
|
session.setListener(new MediaSessionListener());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,6 +283,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* Removes a {@link MediaSession} from this service. This is not necessary for most media apps.
|
* Removes a {@link MediaSession} from this service. This is not necessary for most media apps.
|
||||||
* See <a href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
|
* See <a href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
|
||||||
*
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
|
*
|
||||||
* @param session A session to be removed.
|
* @param session A session to be removed.
|
||||||
* @see #addSession(MediaSession)
|
* @see #addSession(MediaSession)
|
||||||
* @see #getSessions()
|
* @see #getSessions()
|
||||||
|
|
@ -288,13 +296,19 @@ public abstract class MediaSessionService extends Service {
|
||||||
sessions.remove(session.getId());
|
sessions.remove(session.getId());
|
||||||
}
|
}
|
||||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||||
postOrRun(mainHandler, () -> notificationManager.removeSession(session));
|
postOrRun(
|
||||||
|
mainHandler,
|
||||||
|
() -> {
|
||||||
|
notificationManager.removeSession(session);
|
||||||
session.clearListener();
|
session.clearListener();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of {@linkplain MediaSession sessions} that you've added to this service via
|
* Returns the list of {@linkplain MediaSession sessions} that you've added to this service via
|
||||||
* {@link #addSession} or {@link #onGetSession(ControllerInfo)}.
|
* {@link #addSession} or {@link #onGetSession(ControllerInfo)}.
|
||||||
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
*/
|
*/
|
||||||
public final List<MediaSession> getSessions() {
|
public final List<MediaSession> getSessions() {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
|
@ -305,6 +319,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
/**
|
/**
|
||||||
* Returns whether {@code session} has been added to this service via {@link #addSession} or
|
* Returns whether {@code session} has been added to this service via {@link #addSession} or
|
||||||
* {@link #onGetSession(ControllerInfo)}.
|
* {@link #onGetSession(ControllerInfo)}.
|
||||||
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
*/
|
*/
|
||||||
public final boolean isSessionAdded(MediaSession session) {
|
public final boolean isSessionAdded(MediaSession session) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
|
@ -312,7 +328,11 @@ public abstract class MediaSessionService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the {@linkplain Listener listener}. */
|
/**
|
||||||
|
* Sets the {@linkplain Listener listener}.
|
||||||
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public final void setListener(Listener listener) {
|
public final void setListener(Listener listener) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
|
@ -320,7 +340,11 @@ public abstract class MediaSessionService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears the {@linkplain Listener listener}. */
|
/**
|
||||||
|
* Clears the {@linkplain Listener listener}.
|
||||||
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public final void clearListener() {
|
public final void clearListener() {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
|
@ -335,6 +359,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* controllers}. In this case, the intent will have the action {@link #SERVICE_INTERFACE}.
|
* controllers}. In this case, the intent will have the action {@link #SERVICE_INTERFACE}.
|
||||||
* Override this method if this service also needs to handle actions other than {@link
|
* Override this method if this service also needs to handle actions other than {@link
|
||||||
* #SERVICE_INTERFACE}.
|
* #SERVICE_INTERFACE}.
|
||||||
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
*/
|
*/
|
||||||
@CallSuper
|
@CallSuper
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -378,6 +404,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* <p>The default implementation handles the incoming media button events. In this case, the
|
* <p>The default implementation handles the incoming media button events. In this case, the
|
||||||
* intent will have the action {@link Intent#ACTION_MEDIA_BUTTON}. Override this method if this
|
* intent will have the action {@link Intent#ACTION_MEDIA_BUTTON}. Override this method if this
|
||||||
* service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}.
|
* service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}.
|
||||||
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
*/
|
*/
|
||||||
@CallSuper
|
@CallSuper
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -417,6 +445,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* Called when the service is no longer used and is being removed.
|
* Called when the service is no longer used and is being removed.
|
||||||
*
|
*
|
||||||
* <p>Override this method if you need your own clean up.
|
* <p>Override this method if you need your own clean up.
|
||||||
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
*/
|
*/
|
||||||
@CallSuper
|
@CallSuper
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -456,7 +486,7 @@ public abstract class MediaSessionService extends Service {
|
||||||
* @param session A session that needs notification update.
|
* @param session A session that needs notification update.
|
||||||
*/
|
*/
|
||||||
public void onUpdateNotification(MediaSession session) {
|
public void onUpdateNotification(MediaSession session) {
|
||||||
setDefaultMethodCalled(true);
|
defaultMethodCalled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -483,13 +513,15 @@ public abstract class MediaSessionService extends Service {
|
||||||
* <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
|
* <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
|
||||||
* android.Manifest.permission#FOREGROUND_SERVICE}.
|
* android.Manifest.permission#FOREGROUND_SERVICE}.
|
||||||
*
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
|
*
|
||||||
* @param session A session that needs notification update.
|
* @param session A session that needs notification update.
|
||||||
* @param startInForegroundRequired Whether the service is required to start in the foreground.
|
* @param startInForegroundRequired Whether the service is required to start in the foreground.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
|
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
|
||||||
onUpdateNotification(session);
|
onUpdateNotification(session);
|
||||||
if (isDefaultMethodCalled()) {
|
if (defaultMethodCalled) {
|
||||||
getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
|
getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -498,6 +530,8 @@ public abstract class MediaSessionService extends Service {
|
||||||
* Sets the {@link MediaNotification.Provider} to customize notifications.
|
* Sets the {@link MediaNotification.Provider} to customize notifications.
|
||||||
*
|
*
|
||||||
* <p>This should be called before {@link #onCreate()} returns.
|
* <p>This should be called before {@link #onCreate()} returns.
|
||||||
|
*
|
||||||
|
* <p>This method can be called from any thread.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
protected final void setMediaNotificationProvider(
|
protected final void setMediaNotificationProvider(
|
||||||
|
|
@ -514,11 +548,16 @@ public abstract class MediaSessionService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers notification update and handles {@code ForegroundServiceStartNotAllowedException}.
|
||||||
|
*
|
||||||
|
* <p>This method will be called on the main thread.
|
||||||
|
*/
|
||||||
/* package */ boolean onUpdateNotificationInternal(
|
/* package */ boolean onUpdateNotificationInternal(
|
||||||
MediaSession session, boolean startInForegroundWhenPaused) {
|
MediaSession session, boolean startInForegroundWhenPaused) {
|
||||||
try {
|
try {
|
||||||
boolean startInForegroundRequired =
|
boolean startInForegroundRequired =
|
||||||
shouldRunInForeground(session, startInForegroundWhenPaused);
|
getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused);
|
||||||
onUpdateNotification(session, startInForegroundRequired);
|
onUpdateNotification(session, startInForegroundRequired);
|
||||||
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
|
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
|
||||||
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
|
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
|
||||||
|
|
@ -531,14 +570,6 @@ public abstract class MediaSessionService extends Service {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* package */ static boolean shouldRunInForeground(
|
|
||||||
MediaSession session, boolean startInForegroundWhenPaused) {
|
|
||||||
Player player = session.getPlayer();
|
|
||||||
return (player.getPlayWhenReady() || startInForegroundWhenPaused)
|
|
||||||
&& (player.getPlaybackState() == Player.STATE_READY
|
|
||||||
|| player.getPlaybackState() == Player.STATE_BUFFERING);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MediaNotificationManager getMediaNotificationManager() {
|
private MediaNotificationManager getMediaNotificationManager() {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
if (mediaNotificationManager == null) {
|
if (mediaNotificationManager == null) {
|
||||||
|
|
@ -570,18 +601,6 @@ public abstract class MediaSessionService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isDefaultMethodCalled() {
|
|
||||||
synchronized (lock) {
|
|
||||||
return this.defaultMethodCalled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setDefaultMethodCalled(boolean defaultMethodCalled) {
|
|
||||||
synchronized (lock) {
|
|
||||||
this.defaultMethodCalled = defaultMethodCalled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(31)
|
@RequiresApi(31)
|
||||||
private void onForegroundServiceStartNotAllowedException() {
|
private void onForegroundServiceStartNotAllowedException() {
|
||||||
mainHandler.post(
|
mainHandler.post(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@ import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.PlaybackParameters;
|
import androidx.media3.common.PlaybackParameters;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
import androidx.media3.common.Rating;
|
import androidx.media3.common.Rating;
|
||||||
|
import androidx.media3.common.TrackGroup;
|
||||||
|
import androidx.media3.common.TrackSelectionOverride;
|
||||||
import androidx.media3.common.TrackSelectionParameters;
|
import androidx.media3.common.TrackSelectionParameters;
|
||||||
|
import androidx.media3.common.Tracks;
|
||||||
import androidx.media3.common.util.Assertions;
|
import androidx.media3.common.util.Assertions;
|
||||||
import androidx.media3.common.util.BundleableUtil;
|
import androidx.media3.common.util.BundleableUtil;
|
||||||
import androidx.media3.common.util.Consumer;
|
import androidx.media3.common.util.Consumer;
|
||||||
|
|
@ -82,6 +85,7 @@ import androidx.media3.session.MediaSession.ControllerCb;
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo;
|
import androidx.media3.session.MediaSession.ControllerInfo;
|
||||||
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
|
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
|
||||||
import androidx.media3.session.SessionCommand.CommandCode;
|
import androidx.media3.session.SessionCommand.CommandCode;
|
||||||
|
import com.google.common.collect.ImmutableBiMap;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -113,6 +117,9 @@ import java.util.concurrent.ExecutionException;
|
||||||
private final ConnectedControllersManager<IBinder> connectedControllersManager;
|
private final ConnectedControllersManager<IBinder> connectedControllersManager;
|
||||||
private final Set<ControllerInfo> pendingControllers;
|
private final Set<ControllerInfo> pendingControllers;
|
||||||
|
|
||||||
|
private ImmutableBiMap<TrackGroup, String> trackGroupIdMap;
|
||||||
|
private int nextUniqueTrackGroupIdPrefix;
|
||||||
|
|
||||||
public MediaSessionStub(MediaSessionImpl sessionImpl) {
|
public MediaSessionStub(MediaSessionImpl sessionImpl) {
|
||||||
// Initialize members with params.
|
// Initialize members with params.
|
||||||
this.sessionImpl = new WeakReference<>(sessionImpl);
|
this.sessionImpl = new WeakReference<>(sessionImpl);
|
||||||
|
|
@ -120,6 +127,7 @@ import java.util.concurrent.ExecutionException;
|
||||||
connectedControllersManager = new ConnectedControllersManager<>(sessionImpl);
|
connectedControllersManager = new ConnectedControllersManager<>(sessionImpl);
|
||||||
// ConcurrentHashMap has a bug in APIs 21-22 that can result in lost updates.
|
// ConcurrentHashMap has a bug in APIs 21-22 that can result in lost updates.
|
||||||
pendingControllers = Collections.synchronizedSet(new HashSet<>());
|
pendingControllers = Collections.synchronizedSet(new HashSet<>());
|
||||||
|
trackGroupIdMap = ImmutableBiMap.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConnectedControllersManager<IBinder> getConnectedControllersManager() {
|
public ConnectedControllersManager<IBinder> getConnectedControllersManager() {
|
||||||
|
|
@ -493,6 +501,7 @@ import java.util.concurrent.ExecutionException;
|
||||||
// session/controller.
|
// session/controller.
|
||||||
PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
|
PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
|
||||||
PlayerInfo playerInfo = playerWrapper.createPlayerInfoForBundling();
|
PlayerInfo playerInfo = playerWrapper.createPlayerInfoForBundling();
|
||||||
|
playerInfo = generateAndCacheUniqueTrackGroupIds(playerInfo);
|
||||||
ConnectionState state =
|
ConnectionState state =
|
||||||
new ConnectionState(
|
new ConnectionState(
|
||||||
MediaLibraryInfo.VERSION_INT,
|
MediaLibraryInfo.VERSION_INT,
|
||||||
|
|
@ -1435,7 +1444,11 @@ import java.util.concurrent.ExecutionException;
|
||||||
sequenceNumber,
|
sequenceNumber,
|
||||||
COMMAND_SET_TRACK_SELECTION_PARAMETERS,
|
COMMAND_SET_TRACK_SELECTION_PARAMETERS,
|
||||||
sendSessionResultSuccess(
|
sendSessionResultSuccess(
|
||||||
player -> player.setTrackSelectionParameters(trackSelectionParameters)));
|
player -> {
|
||||||
|
TrackSelectionParameters updatedParameters =
|
||||||
|
updateOverridesUsingUniqueTrackGroupIds(trackSelectionParameters);
|
||||||
|
player.setTrackSelectionParameters(updatedParameters);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -1622,6 +1635,65 @@ import java.util.concurrent.ExecutionException;
|
||||||
librarySessionImpl.onUnsubscribeOnHandler(controller, parentId)));
|
librarySessionImpl.onUnsubscribeOnHandler(controller, parentId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* package */ PlayerInfo generateAndCacheUniqueTrackGroupIds(PlayerInfo playerInfo) {
|
||||||
|
ImmutableList<Tracks.Group> trackGroups = playerInfo.currentTracks.getGroups();
|
||||||
|
ImmutableList.Builder<Tracks.Group> updatedTrackGroups = ImmutableList.builder();
|
||||||
|
ImmutableBiMap.Builder<TrackGroup, String> updatedTrackGroupIdMap = ImmutableBiMap.builder();
|
||||||
|
for (int i = 0; i < trackGroups.size(); i++) {
|
||||||
|
Tracks.Group trackGroup = trackGroups.get(i);
|
||||||
|
TrackGroup mediaTrackGroup = trackGroup.getMediaTrackGroup();
|
||||||
|
@Nullable String uniqueId = trackGroupIdMap.get(mediaTrackGroup);
|
||||||
|
if (uniqueId == null) {
|
||||||
|
uniqueId = generateUniqueTrackGroupId(mediaTrackGroup);
|
||||||
|
}
|
||||||
|
updatedTrackGroupIdMap.put(mediaTrackGroup, uniqueId);
|
||||||
|
updatedTrackGroups.add(trackGroup.copyWithId(uniqueId));
|
||||||
|
}
|
||||||
|
trackGroupIdMap = updatedTrackGroupIdMap.buildOrThrow();
|
||||||
|
playerInfo = playerInfo.copyWithCurrentTracks(new Tracks(updatedTrackGroups.build()));
|
||||||
|
if (playerInfo.trackSelectionParameters.overrides.isEmpty()) {
|
||||||
|
return playerInfo;
|
||||||
|
}
|
||||||
|
TrackSelectionParameters.Builder updatedTrackSelectionParameters =
|
||||||
|
playerInfo.trackSelectionParameters.buildUpon().clearOverrides();
|
||||||
|
for (TrackSelectionOverride override : playerInfo.trackSelectionParameters.overrides.values()) {
|
||||||
|
TrackGroup trackGroup = override.mediaTrackGroup;
|
||||||
|
@Nullable String uniqueId = trackGroupIdMap.get(trackGroup);
|
||||||
|
if (uniqueId != null) {
|
||||||
|
updatedTrackSelectionParameters.addOverride(
|
||||||
|
new TrackSelectionOverride(trackGroup.copyWithId(uniqueId), override.trackIndices));
|
||||||
|
} else {
|
||||||
|
updatedTrackSelectionParameters.addOverride(override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerInfo.copyWithTrackSelectionParameters(updatedTrackSelectionParameters.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrackSelectionParameters updateOverridesUsingUniqueTrackGroupIds(
|
||||||
|
TrackSelectionParameters trackSelectionParameters) {
|
||||||
|
if (trackSelectionParameters.overrides.isEmpty()) {
|
||||||
|
return trackSelectionParameters;
|
||||||
|
}
|
||||||
|
TrackSelectionParameters.Builder updateTrackSelectionParameters =
|
||||||
|
trackSelectionParameters.buildUpon().clearOverrides();
|
||||||
|
for (TrackSelectionOverride override : trackSelectionParameters.overrides.values()) {
|
||||||
|
TrackGroup trackGroup = override.mediaTrackGroup;
|
||||||
|
@Nullable TrackGroup originalTrackGroup = trackGroupIdMap.inverse().get(trackGroup.id);
|
||||||
|
if (originalTrackGroup != null
|
||||||
|
&& override.mediaTrackGroup.length == originalTrackGroup.length) {
|
||||||
|
updateTrackSelectionParameters.addOverride(
|
||||||
|
new TrackSelectionOverride(originalTrackGroup, override.trackIndices));
|
||||||
|
} else {
|
||||||
|
updateTrackSelectionParameters.addOverride(override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updateTrackSelectionParameters.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateUniqueTrackGroupId(TrackGroup trackGroup) {
|
||||||
|
return Util.intToStringMaxRadix(nextUniqueTrackGroupIdPrefix++) + "-" + trackGroup.id;
|
||||||
|
}
|
||||||
|
|
||||||
/** Common interface for code snippets to handle all incoming commands from the controller. */
|
/** Common interface for code snippets to handle all incoming commands from the controller. */
|
||||||
private interface SessionTask<T, K extends MediaSessionImpl> {
|
private interface SessionTask<T, K extends MediaSessionImpl> {
|
||||||
T run(K sessionImpl, ControllerInfo controller, int sequenceNumber);
|
T run(K sessionImpl, ControllerInfo controller, int sequenceNumber);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
package androidx.media3.session;
|
package androidx.media3.session;
|
||||||
|
|
||||||
import static androidx.media3.common.util.Assertions.checkArgument;
|
import static androidx.media3.common.util.Assertions.checkArgument;
|
||||||
import static androidx.media3.common.util.Assertions.checkNotNull;
|
|
||||||
|
|
||||||
import android.support.v4.media.MediaMetadataCompat;
|
import android.support.v4.media.MediaMetadataCompat;
|
||||||
import android.support.v4.media.session.MediaSessionCompat.QueueItem;
|
import android.support.v4.media.session.MediaSessionCompat.QueueItem;
|
||||||
|
|
@ -27,11 +26,8 @@ import androidx.media3.common.Timeline;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
import com.google.common.base.Objects;
|
import com.google.common.base.Objects;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.collect.ImmutableMap;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An immutable class to represent the current {@link Timeline} backed by {@linkplain QueueItem
|
* An immutable class to represent the current {@link Timeline} backed by {@linkplain QueueItem
|
||||||
|
|
@ -45,42 +41,33 @@ import java.util.Map;
|
||||||
/* package */ final class QueueTimeline extends Timeline {
|
/* package */ final class QueueTimeline extends Timeline {
|
||||||
|
|
||||||
public static final QueueTimeline DEFAULT =
|
public static final QueueTimeline DEFAULT =
|
||||||
new QueueTimeline(ImmutableList.of(), ImmutableMap.of(), /* fakeMediaItem= */ null);
|
new QueueTimeline(ImmutableList.of(), /* fakeMediaItem= */ null);
|
||||||
|
|
||||||
private static final Object FAKE_WINDOW_UID = new Object();
|
private static final Object FAKE_WINDOW_UID = new Object();
|
||||||
|
|
||||||
private final ImmutableList<MediaItem> mediaItems;
|
private final ImmutableList<QueuedMediaItem> queuedMediaItems;
|
||||||
private final ImmutableMap<MediaItem, Long> mediaItemToQueueIdMap;
|
|
||||||
@Nullable private final MediaItem fakeMediaItem;
|
@Nullable private final MediaItem fakeMediaItem;
|
||||||
|
|
||||||
/** Creates a new instance. */
|
|
||||||
public QueueTimeline(QueueTimeline queueTimeline) {
|
|
||||||
this.mediaItems = queueTimeline.mediaItems;
|
|
||||||
this.mediaItemToQueueIdMap = queueTimeline.mediaItemToQueueIdMap;
|
|
||||||
this.fakeMediaItem = queueTimeline.fakeMediaItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
private QueueTimeline(
|
private QueueTimeline(
|
||||||
ImmutableList<MediaItem> mediaItems,
|
ImmutableList<QueuedMediaItem> queuedMediaItems, @Nullable MediaItem fakeMediaItem) {
|
||||||
ImmutableMap<MediaItem, Long> mediaItemToQueueIdMap,
|
this.queuedMediaItems = queuedMediaItems;
|
||||||
@Nullable MediaItem fakeMediaItem) {
|
|
||||||
this.mediaItems = mediaItems;
|
|
||||||
this.mediaItemToQueueIdMap = mediaItemToQueueIdMap;
|
|
||||||
this.fakeMediaItem = fakeMediaItem;
|
this.fakeMediaItem = fakeMediaItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */
|
/** Creates a {@link QueueTimeline} from a list of {@linkplain QueueItem queue items}. */
|
||||||
public static QueueTimeline create(List<QueueItem> queue) {
|
public static QueueTimeline create(List<QueueItem> queue) {
|
||||||
ImmutableList.Builder<MediaItem> mediaItemsBuilder = new ImmutableList.Builder<>();
|
ImmutableList.Builder<QueuedMediaItem> queuedMediaItemsBuilder = new ImmutableList.Builder<>();
|
||||||
ImmutableMap.Builder<MediaItem, Long> mediaItemToQueueIdMap = new ImmutableMap.Builder<>();
|
|
||||||
for (int i = 0; i < queue.size(); i++) {
|
for (int i = 0; i < queue.size(); i++) {
|
||||||
QueueItem queueItem = queue.get(i);
|
QueueItem queueItem = queue.get(i);
|
||||||
MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem);
|
MediaItem mediaItem = MediaUtils.convertToMediaItem(queueItem);
|
||||||
mediaItemsBuilder.add(mediaItem);
|
queuedMediaItemsBuilder.add(new QueuedMediaItem(mediaItem, queueItem.getQueueId()));
|
||||||
mediaItemToQueueIdMap.put(mediaItem, queueItem.getQueueId());
|
|
||||||
}
|
}
|
||||||
return new QueueTimeline(
|
return new QueueTimeline(queuedMediaItemsBuilder.build(), /* fakeMediaItem= */ null);
|
||||||
mediaItemsBuilder.build(), mediaItemToQueueIdMap.buildOrThrow(), /* fakeMediaItem= */ null);
|
}
|
||||||
|
|
||||||
|
/** Returns a copy of the current queue timeline. */
|
||||||
|
public QueueTimeline copy() {
|
||||||
|
return new QueueTimeline(queuedMediaItems, fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -91,9 +78,9 @@ import java.util.Map;
|
||||||
* @return The corresponding queue ID or {@link QueueItem#UNKNOWN_ID} if not known.
|
* @return The corresponding queue ID or {@link QueueItem#UNKNOWN_ID} if not known.
|
||||||
*/
|
*/
|
||||||
public long getQueueId(int mediaItemIndex) {
|
public long getQueueId(int mediaItemIndex) {
|
||||||
MediaItem mediaItem = getMediaItemAt(mediaItemIndex);
|
return mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()
|
||||||
@Nullable Long queueId = mediaItemToQueueIdMap.get(mediaItem);
|
? queuedMediaItems.get(mediaItemIndex).queueId
|
||||||
return queueId == null ? QueueItem.UNKNOWN_ID : queueId;
|
: QueueItem.UNKNOWN_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -103,7 +90,7 @@ import java.util.Map;
|
||||||
* @return A new {@link QueueTimeline} reflecting the update.
|
* @return A new {@link QueueTimeline} reflecting the update.
|
||||||
*/
|
*/
|
||||||
public QueueTimeline copyWithFakeMediaItem(@Nullable MediaItem fakeMediaItem) {
|
public QueueTimeline copyWithFakeMediaItem(@Nullable MediaItem fakeMediaItem) {
|
||||||
return new QueueTimeline(mediaItems, mediaItemToQueueIdMap, fakeMediaItem);
|
return new QueueTimeline(queuedMediaItems, fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -115,23 +102,17 @@ import java.util.Map;
|
||||||
*/
|
*/
|
||||||
public QueueTimeline copyWithNewMediaItem(int replaceIndex, MediaItem newMediaItem) {
|
public QueueTimeline copyWithNewMediaItem(int replaceIndex, MediaItem newMediaItem) {
|
||||||
checkArgument(
|
checkArgument(
|
||||||
replaceIndex < mediaItems.size()
|
replaceIndex < queuedMediaItems.size()
|
||||||
|| (replaceIndex == mediaItems.size() && fakeMediaItem != null));
|
|| (replaceIndex == queuedMediaItems.size() && fakeMediaItem != null));
|
||||||
if (replaceIndex == mediaItems.size()) {
|
if (replaceIndex == queuedMediaItems.size()) {
|
||||||
return new QueueTimeline(mediaItems, mediaItemToQueueIdMap, newMediaItem);
|
return new QueueTimeline(queuedMediaItems, newMediaItem);
|
||||||
}
|
}
|
||||||
MediaItem oldMediaItem = mediaItems.get(replaceIndex);
|
long queueId = queuedMediaItems.get(replaceIndex).queueId;
|
||||||
// Create the new play list.
|
ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
|
||||||
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>();
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(0, replaceIndex));
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(0, replaceIndex));
|
queuedItemsBuilder.add(new QueuedMediaItem(newMediaItem, queueId));
|
||||||
newMediaItemsBuilder.add(newMediaItem);
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(replaceIndex + 1, queuedMediaItems.size()));
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(replaceIndex + 1, mediaItems.size()));
|
return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem);
|
||||||
// Update the map of items to queue IDs accordingly.
|
|
||||||
Map<MediaItem, Long> newMediaItemToQueueIdMap = new HashMap<>(mediaItemToQueueIdMap);
|
|
||||||
Long queueId = checkNotNull(newMediaItemToQueueIdMap.remove(oldMediaItem));
|
|
||||||
newMediaItemToQueueIdMap.put(newMediaItem, queueId);
|
|
||||||
return new QueueTimeline(
|
|
||||||
newMediaItemsBuilder.build(), ImmutableMap.copyOf(newMediaItemToQueueIdMap), fakeMediaItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,11 +124,13 @@ import java.util.Map;
|
||||||
* @return A new {@link QueueTimeline} reflecting the update.
|
* @return A new {@link QueueTimeline} reflecting the update.
|
||||||
*/
|
*/
|
||||||
public QueueTimeline copyWithNewMediaItems(int index, List<MediaItem> newMediaItems) {
|
public QueueTimeline copyWithNewMediaItems(int index, List<MediaItem> newMediaItems) {
|
||||||
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>();
|
ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(0, index));
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(0, index));
|
||||||
newMediaItemsBuilder.addAll(newMediaItems);
|
for (int i = 0; i < newMediaItems.size(); i++) {
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(index, mediaItems.size()));
|
queuedItemsBuilder.add(new QueuedMediaItem(newMediaItems.get(i), QueueItem.UNKNOWN_ID));
|
||||||
return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem);
|
}
|
||||||
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(index, queuedMediaItems.size()));
|
||||||
|
return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -158,10 +141,10 @@ import java.util.Map;
|
||||||
* @return A new {@link QueueTimeline} reflecting the update.
|
* @return A new {@link QueueTimeline} reflecting the update.
|
||||||
*/
|
*/
|
||||||
public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) {
|
public QueueTimeline copyWithRemovedMediaItems(int fromIndex, int toIndex) {
|
||||||
ImmutableList.Builder<MediaItem> newMediaItemsBuilder = new ImmutableList.Builder<>();
|
ImmutableList.Builder<QueuedMediaItem> queuedItemsBuilder = new ImmutableList.Builder<>();
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(0, fromIndex));
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(0, fromIndex));
|
||||||
newMediaItemsBuilder.addAll(mediaItems.subList(toIndex, mediaItems.size()));
|
queuedItemsBuilder.addAll(queuedMediaItems.subList(toIndex, queuedMediaItems.size()));
|
||||||
return new QueueTimeline(newMediaItemsBuilder.build(), mediaItemToQueueIdMap, fakeMediaItem);
|
return new QueueTimeline(queuedItemsBuilder.build(), fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -173,50 +156,45 @@ import java.util.Map;
|
||||||
* @return A new {@link QueueTimeline} reflecting the update.
|
* @return A new {@link QueueTimeline} reflecting the update.
|
||||||
*/
|
*/
|
||||||
public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) {
|
public QueueTimeline copyWithMovedMediaItems(int fromIndex, int toIndex, int newIndex) {
|
||||||
List<MediaItem> list = new ArrayList<>(mediaItems);
|
List<QueuedMediaItem> list = new ArrayList<>(queuedMediaItems);
|
||||||
Util.moveItems(list, fromIndex, toIndex, newIndex);
|
Util.moveItems(list, fromIndex, toIndex, newIndex);
|
||||||
return new QueueTimeline(
|
return new QueueTimeline(ImmutableList.copyOf(list), fakeMediaItem);
|
||||||
new ImmutableList.Builder<MediaItem>().addAll(list).build(),
|
|
||||||
mediaItemToQueueIdMap,
|
|
||||||
fakeMediaItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Returns whether the timeline contains the given {@link MediaItem}. */
|
||||||
* Returns the media item index of the given media item in the timeline, or {@link C#INDEX_UNSET}
|
public boolean contains(MediaItem mediaItem) {
|
||||||
* if the item is not part of this timeline.
|
|
||||||
*
|
|
||||||
* @param mediaItem The media item of interest.
|
|
||||||
* @return The index of the item or {@link C#INDEX_UNSET} if the item is not part of the timeline.
|
|
||||||
*/
|
|
||||||
public int indexOf(MediaItem mediaItem) {
|
|
||||||
if (mediaItem.equals(fakeMediaItem)) {
|
if (mediaItem.equals(fakeMediaItem)) {
|
||||||
return mediaItems.size();
|
return true;
|
||||||
}
|
}
|
||||||
int mediaItemIndex = mediaItems.indexOf(mediaItem);
|
for (int i = 0; i < queuedMediaItems.size(); i++) {
|
||||||
return mediaItemIndex == -1 ? C.INDEX_UNSET : mediaItemIndex;
|
if (mediaItem.equals(queuedMediaItems.get(i).mediaItem)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public MediaItem getMediaItemAt(int mediaItemIndex) {
|
public MediaItem getMediaItemAt(int mediaItemIndex) {
|
||||||
if (mediaItemIndex >= 0 && mediaItemIndex < mediaItems.size()) {
|
if (mediaItemIndex >= 0 && mediaItemIndex < queuedMediaItems.size()) {
|
||||||
return mediaItems.get(mediaItemIndex);
|
return queuedMediaItems.get(mediaItemIndex).mediaItem;
|
||||||
}
|
}
|
||||||
return (mediaItemIndex == mediaItems.size()) ? fakeMediaItem : null;
|
return (mediaItemIndex == queuedMediaItems.size()) ? fakeMediaItem : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getWindowCount() {
|
public int getWindowCount() {
|
||||||
return mediaItems.size() + ((fakeMediaItem == null) ? 0 : 1);
|
return queuedMediaItems.size() + ((fakeMediaItem == null) ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
|
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
|
||||||
// TODO(b/149713425): Set duration if it's available from MediaMetadataCompat.
|
// TODO(b/149713425): Set duration if it's available from MediaMetadataCompat.
|
||||||
MediaItem mediaItem;
|
MediaItem mediaItem;
|
||||||
if (windowIndex == mediaItems.size() && fakeMediaItem != null) {
|
if (windowIndex == queuedMediaItems.size() && fakeMediaItem != null) {
|
||||||
mediaItem = fakeMediaItem;
|
mediaItem = fakeMediaItem;
|
||||||
} else {
|
} else {
|
||||||
mediaItem = mediaItems.get(windowIndex);
|
mediaItem = queuedMediaItems.get(windowIndex).mediaItem;
|
||||||
}
|
}
|
||||||
return getWindow(window, mediaItem, windowIndex);
|
return getWindow(window, mediaItem, windowIndex);
|
||||||
}
|
}
|
||||||
|
|
@ -257,14 +235,13 @@ import java.util.Map;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
QueueTimeline other = (QueueTimeline) obj;
|
QueueTimeline other = (QueueTimeline) obj;
|
||||||
return Objects.equal(mediaItems, other.mediaItems)
|
return Objects.equal(queuedMediaItems, other.queuedMediaItems)
|
||||||
&& Objects.equal(mediaItemToQueueIdMap, other.mediaItemToQueueIdMap)
|
|
||||||
&& Objects.equal(fakeMediaItem, other.fakeMediaItem);
|
&& Objects.equal(fakeMediaItem, other.fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
return Objects.hashCode(mediaItems, mediaItemToQueueIdMap, fakeMediaItem);
|
return Objects.hashCode(queuedMediaItems, fakeMediaItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Window getWindow(Window window, MediaItem mediaItem, int windowIndex) {
|
private static Window getWindow(Window window, MediaItem mediaItem, int windowIndex) {
|
||||||
|
|
@ -285,4 +262,35 @@ import java.util.Map;
|
||||||
/* positionInFirstPeriodUs= */ 0);
|
/* positionInFirstPeriodUs= */ 0);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class QueuedMediaItem {
|
||||||
|
|
||||||
|
public final MediaItem mediaItem;
|
||||||
|
public final long queueId;
|
||||||
|
|
||||||
|
public QueuedMediaItem(MediaItem mediaItem, long queueId) {
|
||||||
|
this.mediaItem = mediaItem;
|
||||||
|
this.queueId = queueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(@Nullable Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof QueuedMediaItem)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QueuedMediaItem that = (QueuedMediaItem) o;
|
||||||
|
return queueId == that.queueId && mediaItem.equals(that.mediaItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
int result = 7;
|
||||||
|
result = 31 * result + (int) (queueId ^ (queueId >>> 32));
|
||||||
|
result = 31 * result + mediaItem.hashCode();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,10 @@ public final class SessionCommand implements Bundleable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The extra bundle of a custom command. It will be {@link Bundle#EMPTY} for a predefined command.
|
* The extra bundle of a custom command. It will be {@link Bundle#EMPTY} for a predefined command.
|
||||||
|
*
|
||||||
|
* <p>Interoperability: This value is not used when the command is sent to a legacy {@link
|
||||||
|
* android.support.v4.media.session.MediaSessionCompat} or {@link
|
||||||
|
* android.support.v4.media.session.MediaControllerCompat}.
|
||||||
*/
|
*/
|
||||||
public final Bundle customExtras;
|
public final Bundle customExtras;
|
||||||
|
|
||||||
|
|
@ -143,7 +147,9 @@ public final class SessionCommand implements Bundleable {
|
||||||
* Creates a custom command.
|
* Creates a custom command.
|
||||||
*
|
*
|
||||||
* @param action The action of this custom command.
|
* @param action The action of this custom command.
|
||||||
* @param extras An extra bundle for this custom command.
|
* @param extras An extra bundle for this custom command. This value is not used when the command
|
||||||
|
* is sent to a legacy {@link android.support.v4.media.session.MediaSessionCompat} or {@link
|
||||||
|
* android.support.v4.media.session.MediaControllerCompat}.
|
||||||
*/
|
*/
|
||||||
public SessionCommand(String action, Bundle extras) {
|
public SessionCommand(String action, Bundle extras) {
|
||||||
commandCode = COMMAND_CODE_CUSTOM;
|
commandCode = COMMAND_CODE_CUSTOM;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import android.content.Context;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
@ -785,6 +786,7 @@ public class DefaultMediaNotificationProviderTest {
|
||||||
when(mockMediaSession.getPlayer()).thenReturn(mockPlayer);
|
when(mockMediaSession.getPlayer()).thenReturn(mockPlayer);
|
||||||
MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class);
|
MediaSessionImpl mockMediaSessionImpl = mock(MediaSessionImpl.class);
|
||||||
when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl);
|
when(mockMediaSession.getImpl()).thenReturn(mockMediaSessionImpl);
|
||||||
|
when(mockMediaSessionImpl.getApplicationHandler()).thenReturn(new Handler(Looper.myLooper()));
|
||||||
when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("https://example.test"));
|
when(mockMediaSessionImpl.getUri()).thenReturn(Uri.parse("https://example.test"));
|
||||||
return mockMediaSession;
|
return mockMediaSession;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_MAX_SEEK_TO_PREVIOUS_POSITION_MS = "maxSeekToPreviousPositionMs";
|
||||||
public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters";
|
public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters";
|
||||||
public static final String KEY_CURRENT_TRACKS = "currentTracks";
|
public static final String KEY_CURRENT_TRACKS = "currentTracks";
|
||||||
|
public static final String KEY_AVAILABLE_COMMANDS = "availableCommands";
|
||||||
|
|
||||||
// SessionCompat arguments
|
// SessionCompat arguments
|
||||||
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";
|
public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken";
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,35 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
|
||||||
assertThat(TestUtils.equals(receivedSessionExtras.get(1), sessionExtras)).isTrue();
|
assertThat(TestUtils.equals(receivedSessionExtras.get(1), sessionExtras)).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void broadcastCustomCommand_cnSessionEventCalled() throws Exception {
|
||||||
|
Bundle commandCallExtras = new Bundle();
|
||||||
|
commandCallExtras.putString("key-0", "value-0");
|
||||||
|
// Specify session command extras to see that they are NOT used.
|
||||||
|
Bundle sessionCommandExtras = new Bundle();
|
||||||
|
sessionCommandExtras.putString("key-0", "value-1");
|
||||||
|
SessionCommand sessionCommand = new SessionCommand("custom_action", sessionCommandExtras);
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<String> receivedCommand = new AtomicReference<>();
|
||||||
|
AtomicReference<Bundle> receivedCommandExtras = new AtomicReference<>();
|
||||||
|
MediaControllerCompat.Callback callback =
|
||||||
|
new MediaControllerCompat.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSessionEvent(String event, Bundle extras) {
|
||||||
|
receivedCommand.set(event);
|
||||||
|
receivedCommandExtras.set(extras);
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
controllerCompat.registerCallback(callback, handler);
|
||||||
|
|
||||||
|
session.broadcastCustomCommand(sessionCommand, commandCallExtras);
|
||||||
|
|
||||||
|
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
|
||||||
|
assertThat(receivedCommand.get()).isEqualTo("custom_action");
|
||||||
|
assertThat(TestUtils.equals(receivedCommandExtras.get(), commandCallExtras)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion()
|
public void onMediaItemTransition_updatesLegacyMetadataAndPlaybackState_correctModelConversion()
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
|
@ -1056,7 +1085,8 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void onMediaMetadataChanged_updatesLegacyMetadata_correctModelConversion()
|
public void
|
||||||
|
onMediaMetadataChanged_withGetMetadataAndGetCurrentMediaItemCommand_updatesLegacyMetadata()
|
||||||
throws Exception {
|
throws Exception {
|
||||||
int testItemIndex = 3;
|
int testItemIndex = 3;
|
||||||
String testDisplayTitle = "displayTitle";
|
String testDisplayTitle = "displayTitle";
|
||||||
|
|
@ -1071,6 +1101,12 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
|
||||||
.setMediaId(testMediaItems.get(testItemIndex).mediaId)
|
.setMediaId(testMediaItems.get(testItemIndex).mediaId)
|
||||||
.setMediaMetadata(testMediaMetadata)
|
.setMediaMetadata(testMediaMetadata)
|
||||||
.build());
|
.build());
|
||||||
|
session
|
||||||
|
.getMockPlayer()
|
||||||
|
.notifyAvailableCommandsChanged(
|
||||||
|
new Player.Commands.Builder()
|
||||||
|
.addAll(Player.COMMAND_GET_METADATA, Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
||||||
|
.build());
|
||||||
session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems));
|
session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems));
|
||||||
session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex);
|
session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex);
|
||||||
session.getMockPlayer().setDuration(testDurationMs);
|
session.getMockPlayer().setDuration(testDurationMs);
|
||||||
|
|
@ -1102,6 +1138,49 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
|
||||||
assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId);
|
assertThat(getterMetadataCompat.getString(METADATA_KEY_MEDIA_ID)).isEqualTo(testCurrentMediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onMediaMetadataChanged_withGetMetadataCommandOnly_updatesLegacyMetadata()
|
||||||
|
throws Exception {
|
||||||
|
int testItemIndex = 3;
|
||||||
|
String testDisplayTitle = "displayTitle";
|
||||||
|
List<MediaItem> testMediaItems = MediaTestUtils.createMediaItems(/* size= */ 5);
|
||||||
|
MediaMetadata testMediaMetadata =
|
||||||
|
new MediaMetadata.Builder().setTitle(testDisplayTitle).build();
|
||||||
|
testMediaItems.set(
|
||||||
|
testItemIndex,
|
||||||
|
new MediaItem.Builder()
|
||||||
|
.setMediaId(testMediaItems.get(testItemIndex).mediaId)
|
||||||
|
.setMediaMetadata(testMediaMetadata)
|
||||||
|
.build());
|
||||||
|
session
|
||||||
|
.getMockPlayer()
|
||||||
|
.notifyAvailableCommandsChanged(
|
||||||
|
new Player.Commands.Builder().add(Player.COMMAND_GET_METADATA).build());
|
||||||
|
session.getMockPlayer().setTimeline(new PlaylistTimeline(testMediaItems));
|
||||||
|
session.getMockPlayer().setCurrentMediaItemIndex(testItemIndex);
|
||||||
|
AtomicReference<MediaMetadataCompat> metadataRef = new AtomicReference<>();
|
||||||
|
CountDownLatch latchForMetadata = new CountDownLatch(1);
|
||||||
|
MediaControllerCompat.Callback callback =
|
||||||
|
new MediaControllerCompat.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onMetadataChanged(MediaMetadataCompat metadata) {
|
||||||
|
metadataRef.set(metadata);
|
||||||
|
latchForMetadata.countDown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
controllerCompat.registerCallback(callback, handler);
|
||||||
|
|
||||||
|
session.getMockPlayer().notifyMediaMetadataChanged(testMediaMetadata);
|
||||||
|
|
||||||
|
assertThat(latchForMetadata.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
|
||||||
|
MediaMetadataCompat parameterMetadataCompat = metadataRef.get();
|
||||||
|
MediaMetadataCompat getterMetadataCompat = controllerCompat.getMetadata();
|
||||||
|
assertThat(parameterMetadataCompat.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE))
|
||||||
|
.isEqualTo(testDisplayTitle);
|
||||||
|
assertThat(getterMetadataCompat.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE))
|
||||||
|
.isEqualTo(testDisplayTitle);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void playlistChange() throws Exception {
|
public void playlistChange() throws Exception {
|
||||||
AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>();
|
AtomicReference<List<QueueItem>> queueRef = new AtomicReference<>();
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,8 @@ public class MediaControllerListenerTest {
|
||||||
@Player.RepeatMode int testRepeatMode = Player.REPEAT_MODE_ALL;
|
@Player.RepeatMode int testRepeatMode = Player.REPEAT_MODE_ALL;
|
||||||
int testCurrentAdGroupIndex = 33;
|
int testCurrentAdGroupIndex = 33;
|
||||||
int testCurrentAdIndexInAdGroup = 11;
|
int testCurrentAdIndexInAdGroup = 11;
|
||||||
|
Commands testCommands =
|
||||||
|
new Commands.Builder().addAllCommands().remove(Player.COMMAND_STOP).build();
|
||||||
AtomicInteger stateRef = new AtomicInteger();
|
AtomicInteger stateRef = new AtomicInteger();
|
||||||
AtomicReference<Timeline> timelineRef = new AtomicReference<>();
|
AtomicReference<Timeline> timelineRef = new AtomicReference<>();
|
||||||
AtomicReference<MediaMetadata> playlistMetadataRef = new AtomicReference<>();
|
AtomicReference<MediaMetadata> playlistMetadataRef = new AtomicReference<>();
|
||||||
|
|
@ -335,7 +337,8 @@ public class MediaControllerListenerTest {
|
||||||
AtomicInteger currentAdIndexInAdGroupRef = new AtomicInteger();
|
AtomicInteger currentAdIndexInAdGroupRef = new AtomicInteger();
|
||||||
AtomicBoolean shuffleModeEnabledRef = new AtomicBoolean();
|
AtomicBoolean shuffleModeEnabledRef = new AtomicBoolean();
|
||||||
AtomicInteger repeatModeRef = new AtomicInteger();
|
AtomicInteger repeatModeRef = new AtomicInteger();
|
||||||
CountDownLatch latch = new CountDownLatch(7);
|
AtomicReference<Commands> commandsRef = new AtomicReference<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(8);
|
||||||
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
|
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
|
||||||
threadTestRule
|
threadTestRule
|
||||||
.getHandler()
|
.getHandler()
|
||||||
|
|
@ -343,6 +346,12 @@ public class MediaControllerListenerTest {
|
||||||
() ->
|
() ->
|
||||||
controller.addListener(
|
controller.addListener(
|
||||||
new Player.Listener() {
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onAvailableCommandsChanged(Commands availableCommands) {
|
||||||
|
commandsRef.set(availableCommands);
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioAttributesChanged(AudioAttributes attributes) {
|
public void onAudioAttributesChanged(AudioAttributes attributes) {
|
||||||
audioAttributesRef.set(attributes);
|
audioAttributesRef.set(attributes);
|
||||||
|
|
@ -402,6 +411,7 @@ public class MediaControllerListenerTest {
|
||||||
.setIsPlayingAd(true)
|
.setIsPlayingAd(true)
|
||||||
.setCurrentAdGroupIndex(testCurrentAdGroupIndex)
|
.setCurrentAdGroupIndex(testCurrentAdGroupIndex)
|
||||||
.setCurrentAdIndexInAdGroup(testCurrentAdIndexInAdGroup)
|
.setCurrentAdIndexInAdGroup(testCurrentAdIndexInAdGroup)
|
||||||
|
.setAvailableCommands(testCommands)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
remoteSession.setPlayer(playerConfig);
|
remoteSession.setPlayer(playerConfig);
|
||||||
|
|
@ -415,6 +425,7 @@ public class MediaControllerListenerTest {
|
||||||
assertThat(currentAdIndexInAdGroupRef.get()).isEqualTo(testCurrentAdIndexInAdGroup);
|
assertThat(currentAdIndexInAdGroupRef.get()).isEqualTo(testCurrentAdIndexInAdGroup);
|
||||||
assertThat(shuffleModeEnabledRef.get()).isEqualTo(testShuffleModeEnabled);
|
assertThat(shuffleModeEnabledRef.get()).isEqualTo(testShuffleModeEnabled);
|
||||||
assertThat(repeatModeRef.get()).isEqualTo(testRepeatMode);
|
assertThat(repeatModeRef.get()).isEqualTo(testRepeatMode);
|
||||||
|
assertThat(commandsRef.get()).isEqualTo(testCommands);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -1084,15 +1095,16 @@ public class MediaControllerListenerTest {
|
||||||
|
|
||||||
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
|
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
|
||||||
assertThat(initialCurrentTracksRef.get()).isEqualTo(Tracks.EMPTY);
|
assertThat(initialCurrentTracksRef.get()).isEqualTo(Tracks.EMPTY);
|
||||||
assertThat(changedCurrentTracksFromParamRef.get()).isEqualTo(currentTracks);
|
assertThat(changedCurrentTracksFromParamRef.get().getGroups()).hasSize(2);
|
||||||
assertThat(changedCurrentTracksFromGetterRef.get()).isEqualTo(currentTracks);
|
assertThat(changedCurrentTracksFromGetterRef.get())
|
||||||
|
.isEqualTo(changedCurrentTracksFromParamRef.get());
|
||||||
assertThat(capturedEvents).hasSize(2);
|
assertThat(capturedEvents).hasSize(2);
|
||||||
assertThat(getEventsAsList(capturedEvents.get(0))).containsExactly(Player.EVENT_TRACKS_CHANGED);
|
assertThat(getEventsAsList(capturedEvents.get(0))).containsExactly(Player.EVENT_TRACKS_CHANGED);
|
||||||
assertThat(getEventsAsList(capturedEvents.get(1)))
|
assertThat(getEventsAsList(capturedEvents.get(1)))
|
||||||
.containsExactly(Player.EVENT_IS_LOADING_CHANGED);
|
.containsExactly(Player.EVENT_IS_LOADING_CHANGED);
|
||||||
assertThat(changedCurrentTracksFromOnEvents).hasSize(2);
|
assertThat(changedCurrentTracksFromOnEvents).hasSize(2);
|
||||||
assertThat(changedCurrentTracksFromOnEvents.get(0)).isEqualTo(currentTracks);
|
assertThat(changedCurrentTracksFromOnEvents.get(0).getGroups()).hasSize(2);
|
||||||
assertThat(changedCurrentTracksFromOnEvents.get(1)).isEqualTo(currentTracks);
|
assertThat(changedCurrentTracksFromOnEvents.get(1).getGroups()).hasSize(2);
|
||||||
// Assert that an equal instance is not re-sent over the binder.
|
// Assert that an equal instance is not re-sent over the binder.
|
||||||
assertThat(changedCurrentTracksFromOnEvents.get(0))
|
assertThat(changedCurrentTracksFromOnEvents.get(0))
|
||||||
.isSameInstanceAs(changedCurrentTracksFromOnEvents.get(1));
|
.isSameInstanceAs(changedCurrentTracksFromOnEvents.get(1));
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import androidx.media3.common.IllegalSeekPositionException;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.MediaLibraryInfo;
|
import androidx.media3.common.MediaLibraryInfo;
|
||||||
import androidx.media3.common.MediaMetadata;
|
import androidx.media3.common.MediaMetadata;
|
||||||
|
import androidx.media3.common.Metadata;
|
||||||
import androidx.media3.common.PlaybackException;
|
import androidx.media3.common.PlaybackException;
|
||||||
import androidx.media3.common.PlaybackParameters;
|
import androidx.media3.common.PlaybackParameters;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
|
|
@ -50,6 +51,7 @@ import androidx.media3.common.Rating;
|
||||||
import androidx.media3.common.StarRating;
|
import androidx.media3.common.StarRating;
|
||||||
import androidx.media3.common.Timeline;
|
import androidx.media3.common.Timeline;
|
||||||
import androidx.media3.common.TrackGroup;
|
import androidx.media3.common.TrackGroup;
|
||||||
|
import androidx.media3.common.TrackSelectionOverride;
|
||||||
import androidx.media3.common.TrackSelectionParameters;
|
import androidx.media3.common.TrackSelectionParameters;
|
||||||
import androidx.media3.common.Tracks;
|
import androidx.media3.common.Tracks;
|
||||||
import androidx.media3.common.VideoSize;
|
import androidx.media3.common.VideoSize;
|
||||||
|
|
@ -427,7 +429,7 @@ public class MediaControllerTest {
|
||||||
assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs);
|
assertThat(seekForwardIncrementRef.get()).isEqualTo(seekForwardIncrementMs);
|
||||||
assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs);
|
assertThat(maxSeekToPreviousPositionMsRef.get()).isEqualTo(maxSeekToPreviousPositionMs);
|
||||||
assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters);
|
assertThat(trackSelectionParametersRef.get()).isEqualTo(trackSelectionParameters);
|
||||||
assertThat(currentTracksRef.get()).isEqualTo(currentTracks);
|
assertThat(currentTracksRef.get().getGroups()).hasSize(2);
|
||||||
assertTimelineMediaItemsEquals(timelineRef.get(), timeline);
|
assertTimelineMediaItemsEquals(timelineRef.get(), timeline);
|
||||||
assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex);
|
assertThat(currentMediaItemIndexRef.get()).isEqualTo(currentMediaItemIndex);
|
||||||
assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem);
|
assertThat(currentMediaItemRef.get()).isEqualTo(currentMediaItem);
|
||||||
|
|
@ -1211,6 +1213,118 @@ public class MediaControllerTest {
|
||||||
assertThat(mediaMetadata).isEqualTo(testMediaMetadata);
|
assertThat(mediaMetadata).isEqualTo(testMediaMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getCurrentTracks_hasEqualTrackGroupsForEqualGroupsInPlayer() throws Exception {
|
||||||
|
// Include metadata in Format to ensure the track group can't be fully bundled.
|
||||||
|
Tracks initialPlayerTracks =
|
||||||
|
new Tracks(
|
||||||
|
ImmutableList.of(
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[1],
|
||||||
|
/* trackSelected= */ new boolean[1]),
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[1],
|
||||||
|
/* trackSelected= */ new boolean[1])));
|
||||||
|
Tracks updatedPlayerTracks =
|
||||||
|
new Tracks(
|
||||||
|
ImmutableList.of(
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder().setMetadata(new Metadata()).setId("2").build()),
|
||||||
|
/* adaptiveSupported= */ true,
|
||||||
|
/* trackSupport= */ new int[] {C.FORMAT_HANDLED},
|
||||||
|
/* trackSelected= */ new boolean[] {true}),
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder().setMetadata(new Metadata()).setId("3").build()),
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[1],
|
||||||
|
/* trackSelected= */ new boolean[1])));
|
||||||
|
Bundle playerConfig =
|
||||||
|
new RemoteMediaSession.MockPlayerConfigBuilder()
|
||||||
|
.setCurrentTracks(initialPlayerTracks)
|
||||||
|
.build();
|
||||||
|
remoteSession.setPlayer(playerConfig);
|
||||||
|
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
|
||||||
|
CountDownLatch trackChangedEvent = new CountDownLatch(1);
|
||||||
|
threadTestRule
|
||||||
|
.getHandler()
|
||||||
|
.postAndSync(
|
||||||
|
() ->
|
||||||
|
controller.addListener(
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onTracksChanged(Tracks tracks) {
|
||||||
|
trackChangedEvent.countDown();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Tracks initialControllerTracks =
|
||||||
|
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
|
||||||
|
// Do something unrelated first to ensure tracks are correctly kept even after multiple updates.
|
||||||
|
remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_READY);
|
||||||
|
remoteSession.getMockPlayer().notifyTracksChanged(updatedPlayerTracks);
|
||||||
|
trackChangedEvent.await();
|
||||||
|
Tracks updatedControllerTracks =
|
||||||
|
threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
|
||||||
|
|
||||||
|
assertThat(initialControllerTracks.getGroups()).hasSize(2);
|
||||||
|
assertThat(updatedControllerTracks.getGroups()).hasSize(2);
|
||||||
|
assertThat(initialControllerTracks.getGroups().get(1).getMediaTrackGroup())
|
||||||
|
.isEqualTo(updatedControllerTracks.getGroups().get(0).getMediaTrackGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void getCurrentTracksAndTrackOverrides_haveEqualTrackGroupsForEqualGroupsInPlayer()
|
||||||
|
throws Exception {
|
||||||
|
// Include metadata in Format to ensure the track group can't be fully bundled.
|
||||||
|
TrackGroup playerTrackGroupForOverride =
|
||||||
|
new TrackGroup(new Format.Builder().setMetadata(new Metadata()).setId("2").build());
|
||||||
|
Tracks playerTracks =
|
||||||
|
new Tracks(
|
||||||
|
ImmutableList.of(
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder().setMetadata(new Metadata()).setId("1").build()),
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[1],
|
||||||
|
/* trackSelected= */ new boolean[1]),
|
||||||
|
new Tracks.Group(
|
||||||
|
playerTrackGroupForOverride,
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[1],
|
||||||
|
/* trackSelected= */ new boolean[1])));
|
||||||
|
TrackSelectionParameters trackSelectionParameters =
|
||||||
|
TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
|
||||||
|
.buildUpon()
|
||||||
|
.addOverride(
|
||||||
|
new TrackSelectionOverride(playerTrackGroupForOverride, /* trackIndex= */ 0))
|
||||||
|
.build();
|
||||||
|
Bundle playerConfig =
|
||||||
|
new RemoteMediaSession.MockPlayerConfigBuilder()
|
||||||
|
.setCurrentTracks(playerTracks)
|
||||||
|
.setTrackSelectionParameters(trackSelectionParameters)
|
||||||
|
.build();
|
||||||
|
remoteSession.setPlayer(playerConfig);
|
||||||
|
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
|
||||||
|
|
||||||
|
Tracks controllerTracks = threadTestRule.getHandler().postAndSync(controller::getCurrentTracks);
|
||||||
|
TrackSelectionParameters controllerTrackSelectionParameters =
|
||||||
|
threadTestRule.getHandler().postAndSync(controller::getTrackSelectionParameters);
|
||||||
|
|
||||||
|
TrackGroup controllerTrackGroup = controllerTracks.getGroups().get(1).getMediaTrackGroup();
|
||||||
|
assertThat(controllerTrackSelectionParameters.overrides)
|
||||||
|
.containsExactly(
|
||||||
|
controllerTrackGroup,
|
||||||
|
new TrackSelectionOverride(controllerTrackGroup, /* trackIndex= */ 0));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void
|
public void
|
||||||
setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly()
|
setMediaItems_setLessMediaItemsThanCurrentMediaItemIndex_masksCurrentMediaItemIndexAndStateCorrectly()
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
import androidx.test.ext.truth.os.BundleSubject;
|
import androidx.test.ext.truth.os.BundleSubject;
|
||||||
import androidx.test.filters.MediumTest;
|
import androidx.test.filters.MediumTest;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.collect.Range;
|
import com.google.common.collect.Range;
|
||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -414,6 +415,41 @@ public class MediaControllerWithMediaSessionCompatTest {
|
||||||
assertThat(timelineRef.get().getPeriodCount()).isEqualTo(0);
|
assertThat(timelineRef.get().getPeriodCount()).isEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setQueue_withDuplicatedMediaItems_updatesAndNotifiesTimeline() throws Exception {
|
||||||
|
MediaController controller = controllerTestRule.createController(session.getSessionToken());
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Timeline> timelineFromParamRef = new AtomicReference<>();
|
||||||
|
AtomicReference<Timeline> timelineFromGetterRef = new AtomicReference<>();
|
||||||
|
AtomicInteger reasonRef = new AtomicInteger();
|
||||||
|
Player.Listener listener =
|
||||||
|
new Player.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onTimelineChanged(
|
||||||
|
Timeline timeline, @Player.TimelineChangeReason int reason) {
|
||||||
|
timelineFromParamRef.set(timeline);
|
||||||
|
timelineFromGetterRef.set(controller.getCurrentTimeline());
|
||||||
|
reasonRef.set(reason);
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener));
|
||||||
|
|
||||||
|
List<MediaItem> mediaItems = MediaTestUtils.createMediaItems(/* size= */ 2);
|
||||||
|
Timeline testTimeline =
|
||||||
|
MediaTestUtils.createTimeline(
|
||||||
|
ImmutableList.copyOf(Iterables.concat(mediaItems, mediaItems)));
|
||||||
|
List<QueueItem> testQueue =
|
||||||
|
MediaTestUtils.convertToQueueItemsWithoutBitmap(
|
||||||
|
MediaUtils.convertToMediaItemList(testTimeline));
|
||||||
|
session.setQueue(testQueue);
|
||||||
|
|
||||||
|
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
|
||||||
|
MediaTestUtils.assertMediaIdEquals(testTimeline, timelineFromParamRef.get());
|
||||||
|
MediaTestUtils.assertMediaIdEquals(testTimeline, timelineFromGetterRef.get());
|
||||||
|
assertThat(reasonRef.get()).isEqualTo(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception {
|
public void setQueue_withDescription_notifiesTimelineWithMetadata() throws Exception {
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,21 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import androidx.media3.common.C;
|
||||||
|
import androidx.media3.common.Format;
|
||||||
|
import androidx.media3.common.Metadata;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.common.TrackGroup;
|
||||||
|
import androidx.media3.common.TrackSelectionOverride;
|
||||||
|
import androidx.media3.common.Tracks;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
import androidx.media3.test.session.common.HandlerThreadTestRule;
|
import androidx.media3.test.session.common.HandlerThreadTestRule;
|
||||||
import androidx.media3.test.session.common.MainLooperTestRule;
|
import androidx.media3.test.session.common.MainLooperTestRule;
|
||||||
import androidx.test.core.app.ApplicationProvider;
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
import androidx.test.filters.LargeTest;
|
import androidx.test.filters.LargeTest;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|
@ -248,4 +256,58 @@ public class MediaSessionAndControllerTest {
|
||||||
player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS);
|
player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS);
|
||||||
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
|
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void setTrackSelectionParameters_withOverrides_matchesExpectedTrackGroupInPlayer()
|
||||||
|
throws Exception {
|
||||||
|
MockPlayer player =
|
||||||
|
new MockPlayer.Builder().setApplicationLooper(Looper.getMainLooper()).build();
|
||||||
|
// Intentionally add metadata to the format as this can't be bundled.
|
||||||
|
Tracks.Group trackGroupInPlayer =
|
||||||
|
new Tracks.Group(
|
||||||
|
new TrackGroup(
|
||||||
|
new Format.Builder()
|
||||||
|
.setId("0")
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_H264)
|
||||||
|
.setMetadata(new Metadata())
|
||||||
|
.build(),
|
||||||
|
new Format.Builder()
|
||||||
|
.setId("1")
|
||||||
|
.setSampleMimeType(MimeTypes.VIDEO_H264)
|
||||||
|
.setMetadata(new Metadata())
|
||||||
|
.build()),
|
||||||
|
/* adaptiveSupported= */ false,
|
||||||
|
/* trackSupport= */ new int[] {C.FORMAT_HANDLED, C.FORMAT_HANDLED},
|
||||||
|
/* trackSelected= */ new boolean[] {true, false});
|
||||||
|
player.currentTracks = new Tracks(ImmutableList.of(trackGroupInPlayer));
|
||||||
|
MediaSession session =
|
||||||
|
sessionTestRule.ensureReleaseAfterTest(
|
||||||
|
new MediaSession.Builder(context, player).setId(TAG).build());
|
||||||
|
MediaController controller = controllerTestRule.createController(session.getToken());
|
||||||
|
|
||||||
|
threadTestRule
|
||||||
|
.getHandler()
|
||||||
|
.postAndSync(
|
||||||
|
() ->
|
||||||
|
controller.setTrackSelectionParameters(
|
||||||
|
controller
|
||||||
|
.getTrackSelectionParameters()
|
||||||
|
.buildUpon()
|
||||||
|
.setOverrideForType(
|
||||||
|
new TrackSelectionOverride(
|
||||||
|
controller
|
||||||
|
.getCurrentTracks()
|
||||||
|
.getGroups()
|
||||||
|
.get(0)
|
||||||
|
.getMediaTrackGroup(),
|
||||||
|
/* trackIndex= */ 1))
|
||||||
|
.build()));
|
||||||
|
player.awaitMethodCalled(MockPlayer.METHOD_SET_TRACK_SELECTION_PARAMETERS, TIMEOUT_MS);
|
||||||
|
|
||||||
|
assertThat(player.trackSelectionParameters.overrides)
|
||||||
|
.containsExactly(
|
||||||
|
trackGroupInPlayer.getMediaTrackGroup(),
|
||||||
|
new TrackSelectionOverride(
|
||||||
|
trackGroupInPlayer.getMediaTrackGroup(), /* trackIndex= */ 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package androidx.media3.session;
|
||||||
import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
|
import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
|
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
|
||||||
|
import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
|
||||||
|
|
@ -397,6 +398,10 @@ public class MediaSessionProviderService extends Service {
|
||||||
player.trackSelectionParameters =
|
player.trackSelectionParameters =
|
||||||
TrackSelectionParameters.fromBundle(trackSelectionParametersBundle);
|
TrackSelectionParameters.fromBundle(trackSelectionParametersBundle);
|
||||||
}
|
}
|
||||||
|
@Nullable Bundle availableCommandsBundle = config.getBundle(KEY_AVAILABLE_COMMANDS);
|
||||||
|
if (availableCommandsBundle != null) {
|
||||||
|
player.commands = Player.Commands.CREATOR.fromBundle(availableCommandsBundle);
|
||||||
|
}
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ package androidx.media3.session;
|
||||||
|
|
||||||
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
|
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
|
||||||
|
import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_PERCENTAGE;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_BUFFERED_POSITION;
|
||||||
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
|
import static androidx.media3.test.session.common.CommonConstants.KEY_CONTENT_BUFFERED_POSITION;
|
||||||
|
|
@ -742,6 +743,12 @@ public class RemoteMediaSession {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CanIgnoreReturnValue
|
||||||
|
public MockPlayerConfigBuilder setAvailableCommands(Player.Commands availableCommands) {
|
||||||
|
bundle.putBundle(KEY_AVAILABLE_COMMANDS, availableCommands.toBundle());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public Bundle build() {
|
public Bundle build() {
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ dependencies {
|
||||||
api 'androidx.test.ext:truth:' + androidxTestTruthVersion
|
api 'androidx.test.ext:truth:' + androidxTestTruthVersion
|
||||||
api 'junit:junit:' + junitVersion
|
api 'junit:junit:' + junitVersion
|
||||||
api 'com.google.truth:truth:' + truthVersion
|
api 'com.google.truth:truth:' + truthVersion
|
||||||
|
api 'com.google.truth.extensions:truth-java8-extension:' + truthVersion
|
||||||
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
|
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
|
||||||
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
|
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
|
||||||
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
|
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def addMissingAarTypeToXml(xml) {
|
||||||
"com.google.ads.interactivemedia.v3:interactivemedia",
|
"com.google.ads.interactivemedia.v3:interactivemedia",
|
||||||
"com.google.guava:guava",
|
"com.google.guava:guava",
|
||||||
"com.google.truth:truth",
|
"com.google.truth:truth",
|
||||||
|
"com.google.truth.extensions:truth-java8-extension",
|
||||||
"com.squareup.okhttp3:okhttp",
|
"com.squareup.okhttp3:okhttp",
|
||||||
"com.squareup.okhttp3:mockwebserver",
|
"com.squareup.okhttp3:mockwebserver",
|
||||||
"org.mockito:mockito-core",
|
"org.mockito:mockito-core",
|
||||||
|
|
@ -77,6 +78,11 @@ def addMissingAarTypeToXml(xml) {
|
||||||
(isProjectLibrary
|
(isProjectLibrary
|
||||||
|| aar_dependencies.contains(dependencyName))
|
|| aar_dependencies.contains(dependencyName))
|
||||||
if (!hasJar && !hasAar) {
|
if (!hasJar && !hasAar) {
|
||||||
|
// To look for what kind of dependency it is i.e. aar or jar type,
|
||||||
|
// please expand the External Libraries in Project view in Android Studio
|
||||||
|
// and search for your dependency inside Gradle Script dependencies.
|
||||||
|
// .aar files have @aar suffix at the end of their name,
|
||||||
|
// while .jar files have nothing.
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle")
|
dependencyName + " is not on the JAR or AAR list in missing_aar_type_workaround.gradle")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue