Release media2 extension

PiperOrigin-RevId: 320351394
This commit is contained in:
jaewan 2020-07-09 08:26:35 +00:00 committed by Jaewan Kim
parent 9a42141e66
commit 02f8cdf1d9
40 changed files with 6439 additions and 0 deletions

View File

@ -231,6 +231,9 @@
([#7357](https://github.com/google/ExoPlayer/issues/7357)). ([#7357](https://github.com/google/ExoPlayer/issues/7357)).
* Metadata: Add minimal DVB Application Information Table (AIT) support * Metadata: Add minimal DVB Application Information Table (AIT) support
([#6922](https://github.com/google/ExoPlayer/pull/6922)). ([#6922](https://github.com/google/ExoPlayer/pull/6922)).
* Media2 extension: Publish media2 extension for integrating ExoPlayer with
`androidx.media2.common.SessionPlayer` and
`androidx.media2.session.MediaSession`.
* Cast extension: Implement playlist API and deprecate the old queue * Cast extension: Implement playlist API and deprecate the old queue
manipulation API. manipulation API.
* Demo app: Retain previous position in list of samples. * Demo app: Retain previous position in list of samples.

View File

@ -39,6 +39,7 @@ include modulePrefix + 'extension-ima'
include modulePrefix + 'extension-cast' include modulePrefix + 'extension-cast'
include modulePrefix + 'extension-cronet' include modulePrefix + 'extension-cronet'
include modulePrefix + 'extension-mediasession' include modulePrefix + 'extension-mediasession'
include modulePrefix + 'extension-media2'
include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-okhttp'
include modulePrefix + 'extension-opus' include modulePrefix + 'extension-opus'
include modulePrefix + 'extension-vp9' include modulePrefix + 'extension-vp9'
@ -65,6 +66,7 @@ project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensio
project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast') project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast')
project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet') project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet')
project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession') project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession')
project(modulePrefix + 'extension-media2').projectDir = new File(rootDir, 'extensions/media2')
project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp') project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp')
project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus') project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus')
project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9') project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9')

View File

@ -0,0 +1,53 @@
# ExoPlayer Media2 extension #
The Media2 extension provides builders for [SessionPlayer][] and [MediaSession.SessionCallback][] in
the [Media2 library][].
Compared to [MediaSessionConnector][] that uses [MediaSessionCompat][], this provides finer grained
control for incoming calls, so you can selectively allow/reject commands per controller.
## Getting the extension ##
The easiest way to use the extension is to add it as a gradle dependency:
```gradle
implementation 'com.google.android.exoplayer:extension-media2:2.X.X'
```
where `2.X.X` is the version, which must match the version of the ExoPlayer
library being used.
Alternatively, you can clone the ExoPlayer repository and depend on the module
locally. Instructions for doing this can be found in ExoPlayer's
[top level README][].
[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
## Using the extension ##
### Using `SessionPlayerConnector` ###
`SessionPlayerConnector` is a [SessionPlayer][] implementation wrapping a given `Player`.
You can use a [SessionPlayer][] instance to build a [MediaSession][], or to set the player
associated with a [VideoView][] or [MediaControlView][]
### Using `SessionCallbackBuilder` ###
`SessionCallbackBuilder` lets you build a [MediaSession.SessionCallback][] instance given its
collaborators. You can use a [MediaSession.SessionCallback][] to build a [MediaSession][].
## Links ##
* [Javadoc][]: Classes matching
`com.google.android.exoplayer2.ext.media2.*` belong to this module.
[Javadoc]: https://exoplayer.dev/doc/reference/index.html
[SessionPlayer]: https://developer.android.com/reference/androidx/media2/common/SessionPlayer
[MediaSession]: https://developer.android.com/reference/androidx/media2/session/MediaSession
[MediaSession.SessionCallback]: https://developer.android.com/reference/androidx/media2/session/MediaSession.SessionCallback
[Media2 library]: https://developer.android.com/jetpack/androidx/releases/media2
[MediaSessionCompat]: https://developer.android.com/reference/android/support/v4/media/session/MediaSessionCompat
[MediaSessionConnector]: https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/mediasession/MediaSessionConnector.html
[VideoView]: https://developer.android.com/reference/androidx/media2/widget/VideoView
[MediaControlView]: https://developer.android.com/reference/androidx/media2/widget/MediaControlView

View File

@ -0,0 +1,43 @@
// Copyright 2019 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.
apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle"
android.defaultConfig.minSdkVersion 19
dependencies {
implementation project(modulePrefix + 'library-core')
implementation 'androidx.collection:collection:' + androidxCollectionVersion
implementation 'androidx.concurrent:concurrent-futures:1.0.0'
implementation 'com.google.guava:guava:' + guavaVersion
api 'androidx.media2:media2-session:1.0.3'
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion
androidTestImplementation 'com.google.truth:truth:' + truthVersion
}
ext {
javadocTitle = 'Media2 extension'
}
apply from: '../../javadoc_library.gradle'
ext {
releaseArtifact = 'extension-media2'
releaseDescription = 'Media2 extension for ExoPlayer.'
}
apply from: '../../publish.gradle'

View File

@ -0,0 +1,28 @@
# Proguard rules specific to the media2 extension.
# Constructors and methods accessed via reflection in ExoPlayerUtils.
-dontnote com.google.android.exoplayer2.source.dash.DashMediaSource$Factory
-keepclasseswithmembers class com.google.android.exoplayer2.source.dash.DashMediaSource$Factory {
public <init>(com.google.android.exoplayer2.upstream.DataSource$Factory);
public com.google.android.exoplayer2.source.dash.DashMediaSource$Factory setTag(java.lang.Object);
}
-dontnote com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory
-keepclasseswithmembers class com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory {
public <init>(com.google.android.exoplayer2.upstream.DataSource$Factory);
public com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory setTag(java.lang.Object);
}
-dontnote com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory
-keepclasseswithmembers class com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory {
public <init>(com.google.android.exoplayer2.upstream.DataSource$Factory);
public com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory setTag(java.lang.Object);
}
# Don't warn about checkerframework and Kotlin annotations
-dontwarn org.checkerframework.**
-dontwarn kotlin.annotations.jvm.**
-dontwarn javax.annotation.**
# Work around [internal: b/151134701]: keep non-public versionedparcelable
# classes.
-keep class * implements androidx.versionedparcelable.VersionedParcelable
-keep class androidx.media2.common.MediaParcelUtils$MediaItemParcelImpl

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2016 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.google.android.exoplayer2.ext.media2.test">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-sdk/>
<application
android:allowBackup="false"
tools:ignore="MissingApplicationIcon,HardcodedDebugMode">
<activity android:name="com.google.android.exoplayer2.ext.media2.MediaStubActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="false"
android:label="MediaStubActivity"/>
</application>
<instrumentation
android:targetPackage="com.google.android.exoplayer2.ext.media2.test"
android:name="androidx.test.runner.AndroidJUnitRunner"/>
</manifest>

View File

@ -0,0 +1,99 @@
/*
* Copyright 2020 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 com.google.android.exoplayer2.ext.media2;
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.os.Looper;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.SessionPlayer.PlayerResult;
import androidx.media2.session.MediaSession;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import com.google.android.exoplayer2.ext.media2.test.R;
import com.google.android.exoplayer2.util.Assertions;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link MediaSessionUtil} */
@RunWith(AndroidJUnit4.class)
public class MediaSessionUtilTest {
private static final int PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
@Rule public final PlayerTestRule playerTestRule = new PlayerTestRule();
@Test
public void getSessionCompatToken_withMediaControllerCompat_returnsValidToken() throws Exception {
// Workaround to instantiate MediaSession with public androidx.media dependency.
// TODO(b/146536708): Remove this workaround when the relevant change is released via
// androidx.media 1.2.0.
if (Looper.myLooper() == null) {
Looper.prepare();
}
Context context = ApplicationProvider.getApplicationContext();
SessionPlayerConnector sessionPlayerConnector = playerTestRule.getSessionPlayerConnector();
MediaSession.SessionCallback sessionCallback =
new SessionCallbackBuilder(context, sessionPlayerConnector).build();
TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector);
ListenableFuture<PlayerResult> prepareResult = sessionPlayerConnector.prepare();
CountDownLatch latch = new CountDownLatch(1);
sessionPlayerConnector.registerPlayerCallback(
playerTestRule.getExecutor(),
new SessionPlayer.PlayerCallback() {
@Override
public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) {
if (playerState == SessionPlayer.PLAYER_STATE_PLAYING) {
latch.countDown();
}
}
});
MediaSession session2 =
new MediaSession.Builder(context, sessionPlayerConnector)
.setSessionCallback(playerTestRule.getExecutor(), sessionCallback)
.build();
InstrumentationRegistry.getInstrumentation()
.runOnMainSync(
() -> {
try {
MediaSessionCompat.Token token =
Assertions.checkNotNull(MediaSessionUtil.getSessionCompatToken(session2));
MediaControllerCompat controllerCompat = new MediaControllerCompat(context, token);
controllerCompat.getTransportControls().play();
} catch (Exception e) {
throw new IllegalStateException(e);
}
});
assertThat(
prepareResult
.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)
.getResultCode())
.isEqualTo(PlayerResult.RESULT_SUCCESS);
assertThat(latch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)).isTrue();
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.WindowManager;
import com.google.android.exoplayer2.ext.media2.test.R;
import com.google.android.exoplayer2.util.Util;
/** Stub activity to play media contents on. */
public class MediaStubActivity extends Activity {
private static final String TAG = "MediaStubActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.mediaplayer);
// disable enter animation.
overridePendingTransition(0, 0);
if (Util.SDK_INT >= 27) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
setTurnScreenOn(true);
setShowWhenLocked(true);
KeyguardManager keyguardManager =
(KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
keyguardManager.requestDismissKeyguard(this, null);
} else {
getWindow()
.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
}
}
@Override
public void finish() {
super.finish();
// disable exit animation.
overridePendingTransition(0, 0);
}
@Override
protected void onResume() {
Log.i(TAG, "onResume");
super.onResume();
}
@Override
protected void onPause() {
Log.i(TAG, "onPause");
super.onPause();
}
public SurfaceHolder getSurfaceHolder() {
SurfaceView surface = findViewById(R.id.surface);
return surface.getHolder();
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.content.Context;
import android.media.AudioManager;
import android.os.Looper;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.rules.ExternalResource;
/** Rule for tests that use {@link SessionPlayerConnector}. */
public class PlayerTestRule extends ExternalResource {
private Context context;
private ExecutorService executor;
private SessionPlayerConnector sessionPlayerConnector;
private SimpleExoPlayer exoPlayer;
@Override
protected void before() {
context = ApplicationProvider.getApplicationContext();
executor = Executors.newFixedThreadPool(1);
InstrumentationRegistry.getInstrumentation()
.runOnMainSync(
() -> {
// Initialize AudioManager on the main thread to workaround b/78617702 that
// audio focus listener is called on the thread where the AudioManager was
// originally initialized.
// Without posting this, audio focus listeners wouldn't be called because the
// listeners would be posted to the test thread (here) where it waits until the
// tests are finished.
AudioManager audioManager =
(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
exoPlayer = new SimpleExoPlayer.Builder(context).setLooper(Looper.myLooper()).build();
ConcatenatingMediaSource concatenatingMediaSource = new ConcatenatingMediaSource();
TimelinePlaylistManager manager =
new TimelinePlaylistManager(context, concatenatingMediaSource);
ConcatenatingMediaSourcePlaybackPreparer playbackPreparer =
new ConcatenatingMediaSourcePlaybackPreparer(exoPlayer, concatenatingMediaSource);
sessionPlayerConnector =
new SessionPlayerConnector(exoPlayer, manager, playbackPreparer);
});
}
@Override
protected void after() {
if (sessionPlayerConnector != null) {
sessionPlayerConnector.close();
sessionPlayerConnector = null;
}
if (exoPlayer != null) {
InstrumentationRegistry.getInstrumentation()
.runOnMainSync(
() -> {
exoPlayer.release();
exoPlayer = null;
});
}
if (executor != null) {
executor.shutdown();
executor = null;
}
}
public ExecutorService getExecutor() {
return executor;
}
public SessionPlayerConnector getSessionPlayerConnector() {
return sessionPlayerConnector;
}
public SimpleExoPlayer getSimpleExoPlayer() {
return exoPlayer;
}
}

View File

@ -0,0 +1,710 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResultSuccess;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.Looper;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.media2.common.CallbackMediaItem;
import androidx.media2.common.DataSourceCallback;
import androidx.media2.common.MediaItem;
import androidx.media2.common.Rating;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.UriMediaItem;
import androidx.media2.session.HeartRating;
import androidx.media2.session.MediaController;
import androidx.media2.session.MediaSession;
import androidx.media2.session.SessionCommand;
import androidx.media2.session.SessionCommandGroup;
import androidx.media2.session.SessionResult;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.ext.media2.test.R;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Tests {@link SessionCallbackBuilder}. */
@RunWith(AndroidJUnit4.class)
public class SessionCallbackBuilderTest {
@Rule
public final ActivityTestRule<MediaStubActivity> activityRule =
new ActivityTestRule<>(MediaStubActivity.class);
@Rule public final PlayerTestRule playerTestRule = new PlayerTestRule();
private static final String MEDIA_SESSION_ID = SessionCallbackBuilderTest.class.getSimpleName();
private static final long CONTROLLER_COMMAND_WAIT_TIME_MS = 3_000;
private static final long PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS = 10_000;
private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
private Context context;
private Executor executor;
private SessionPlayerConnector sessionPlayerConnector;
@Before
public void setUp() {
// Workaround to instantiate MediaSession with public androidx.media dependency.
// TODO(jaewan): Remove this workaround when androidx.media 1.2.0 is released.
if (Looper.myLooper() == null) {
Looper.prepare();
}
context = ApplicationProvider.getApplicationContext();
executor = playerTestRule.getExecutor();
sessionPlayerConnector = playerTestRule.getSessionPlayerConnector();
// Sets the surface to the player for manual check.
InstrumentationRegistry.getInstrumentation()
.runOnMainSync(
() -> {
SimpleExoPlayer exoPlayer = playerTestRule.getSimpleExoPlayer();
exoPlayer
.getVideoComponent()
.setVideoSurfaceHolder(activityRule.getActivity().getSurfaceHolder());
});
}
@Test
public void constructor() throws Exception {
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector).build())) {
assertPlayerResultSuccess(
sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(context)));
assertPlayerResultSuccess(sessionPlayerConnector.prepare());
OnConnectedListener listener =
(controller, allowedCommands) -> {
List<Integer> disallowedCommandCodes =
Arrays.asList(
SessionCommand.COMMAND_CODE_SESSION_SET_RATING, // no rating callback
SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM, // no media item provider
SessionCommand
.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM, // no media item provider
SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, // no media item provider
SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST, // no media item provider
SessionCommand.COMMAND_CODE_SESSION_REWIND, // no current media item
SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD // no current media item
);
assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
};
try (MediaController controller = createConnectedController(session, listener, null)) {
assertThat(controller.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED);
}
}
}
@Test
public void allowedCommand_withoutPlaylist_disallowsSkipTo() throws Exception {
int testRewindIncrementMs = 100;
int testFastForwardIncrementMs = 100;
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setRatingCallback(
(mediaSession, controller, mediaId, rating) ->
SessionResult.RESULT_ERROR_BAD_VALUE)
.setRewindIncrementMs(testRewindIncrementMs)
.setFastForwardIncrementMs(testFastForwardIncrementMs)
.setMediaItemProvider(new SessionCallbackBuilder.DefaultMediaItemProvider())
.build())) {
assertPlayerResultSuccess(
sessionPlayerConnector.setMediaItem(TestUtils.createMediaItem(context)));
assertPlayerResultSuccess(sessionPlayerConnector.prepare());
CountDownLatch latch = new CountDownLatch(1);
OnConnectedListener listener =
(controller, allowedCommands) -> {
List<Integer> disallowedCommandCodes =
Arrays.asList(
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM);
assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
latch.countDown();
};
try (MediaController controller = createConnectedController(session, listener, null)) {
assertThat(latch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS)).isTrue();
assertSessionResultFailure(controller.skipToNextPlaylistItem());
assertSessionResultFailure(controller.skipToPreviousPlaylistItem());
assertSessionResultFailure(controller.skipToPlaylistItem(0));
}
}
}
@Test
public void allowedCommand_whenPlaylistSet_allowsSkipTo() throws Exception {
List<MediaItem> testPlaylist = new ArrayList<>();
testPlaylist.add(TestUtils.createMediaItem(context, R.raw.testvideo));
testPlaylist.add(TestUtils.createMediaItem(context, R.raw.sample_not_seekable));
int testRewindIncrementMs = 100;
int testFastForwardIncrementMs = 100;
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setRatingCallback(
(mediaSession, controller, mediaId, rating) ->
SessionResult.RESULT_ERROR_BAD_VALUE)
.setRewindIncrementMs(testRewindIncrementMs)
.setFastForwardIncrementMs(testFastForwardIncrementMs)
.setMediaItemProvider(new SessionCallbackBuilder.DefaultMediaItemProvider())
.build())) {
assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null));
assertPlayerResultSuccess(sessionPlayerConnector.prepare());
OnConnectedListener connectedListener =
(controller, allowedCommands) -> {
List<Integer> allowedCommandCodes =
Arrays.asList(
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO,
SessionCommand.COMMAND_CODE_SESSION_REWIND,
SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD);
assertAllowedCommands(allowedCommandCodes, allowedCommands);
List<Integer> disallowedCommandCodes =
Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM);
assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
};
CountDownLatch allowedCommandChangedLatch = new CountDownLatch(1);
OnAllowedCommandsChangedListener allowedCommandChangedListener =
(controller, allowedCommands) -> {
List<Integer> allowedCommandCodes =
Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM);
assertAllowedCommands(allowedCommandCodes, allowedCommands);
List<Integer> disallowedCommandCodes =
Arrays.asList(
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO,
SessionCommand.COMMAND_CODE_SESSION_REWIND,
SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD);
assertDisallowedCommands(disallowedCommandCodes, allowedCommands);
allowedCommandChangedLatch.countDown();
};
try (MediaController controller =
createConnectedController(session, connectedListener, allowedCommandChangedListener)) {
assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem());
assertThat(
allowedCommandChangedLatch.await(
CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
// Also test whether the rewind fails as expected.
assertSessionResultFailure(controller.rewind());
assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0);
assertThat(controller.getCurrentPosition()).isEqualTo(0);
}
}
}
@Test
public void allowedCommand_afterCurrentMediaItemPrepared_notifiesSeekToAvailable()
throws Exception {
List<MediaItem> testPlaylist = new ArrayList<>();
testPlaylist.add(TestUtils.createMediaItem(context, R.raw.testvideo));
int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz;
TestDataSourceCallback source =
TestDataSourceCallback.fromAssetFd(context.getResources().openRawResourceFd(resid));
CountDownLatch readAllowedLatch = new CountDownLatch(1);
DataSourceCallback dataSource =
new DataSourceCallback() {
@Override
public int readAt(long position, byte[] buffer, int offset, int size) {
try {
assertThat(
readAllowedLatch.await(
PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
} catch (Exception e) {
assertWithMessage("Unexpected exception %s", e).fail();
}
return source.readAt(position, buffer, offset, size);
}
@Override
public long getSize() {
return source.getSize();
}
@Override
public void close() {
source.close();
}
};
testPlaylist.add(new CallbackMediaItem.Builder(dataSource).build());
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector).build())) {
assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(testPlaylist, null));
assertPlayerResultSuccess(sessionPlayerConnector.prepare());
CountDownLatch seekToAllowedForSecondMediaItem = new CountDownLatch(1);
OnAllowedCommandsChangedListener allowedCommandsChangedListener =
(controller, allowedCommands) -> {
if (allowedCommands.hasCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO)
&& controller.getCurrentMediaItemIndex() == 1) {
seekToAllowedForSecondMediaItem.countDown();
}
};
try (MediaController controller =
createConnectedController(
session, /* onConnectedListener= */ null, allowedCommandsChangedListener)) {
assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem());
readAllowedLatch.countDown();
assertThat(
seekToAllowedForSecondMediaItem.await(
CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
}
}
}
@Test
public void setRatingCallback_withRatingCallback_receivesRatingCallback() throws Exception {
String testMediaId = "testRating";
Rating testRating = new HeartRating(true);
CountDownLatch latch = new CountDownLatch(1);
SessionCallbackBuilder.RatingCallback ratingCallback =
(session, controller, mediaId, rating) -> {
assertThat(mediaId).isEqualTo(testMediaId);
assertThat(rating).isEqualTo(testRating);
latch.countDown();
return SessionResult.RESULT_SUCCESS;
};
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setRatingCallback(ratingCallback)
.build())) {
try (MediaController controller = createConnectedController(session)) {
assertSessionResultSuccess(
controller.setRating(testMediaId, testRating), CONTROLLER_COMMAND_WAIT_TIME_MS);
assertThat(latch.await(0, TimeUnit.MILLISECONDS)).isTrue();
}
}
}
@Test
public void setCustomCommandProvider_withCustomCommandProvider_receivesCustomCommand()
throws Exception {
SessionCommand testCommand = new SessionCommand("exo.ext.media2.COMMAND", null);
CountDownLatch latch = new CountDownLatch(1);
SessionCallbackBuilder.CustomCommandProvider provider =
new SessionCallbackBuilder.CustomCommandProvider() {
@Override
public SessionResult onCustomCommand(
MediaSession session,
MediaSession.ControllerInfo controllerInfo,
SessionCommand customCommand,
@Nullable Bundle args) {
assertThat(customCommand.getCustomAction()).isEqualTo(testCommand.getCustomAction());
assertThat(args).isNull();
latch.countDown();
return new SessionResult(SessionResult.RESULT_SUCCESS, null);
}
@Override
public SessionCommandGroup getCustomCommands(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
return new SessionCommandGroup.Builder().addCommand(testCommand).build();
}
};
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setCustomCommandProvider(provider)
.build())) {
OnAllowedCommandsChangedListener listener =
(controller, allowedCommands) -> {
boolean foundCustomCommand = false;
for (SessionCommand command : allowedCommands.getCommands()) {
if (TextUtils.equals(testCommand.getCustomAction(), command.getCustomAction())) {
foundCustomCommand = true;
break;
}
}
assertThat(foundCustomCommand).isTrue();
};
try (MediaController controller = createConnectedController(session, null, listener)) {
assertSessionResultSuccess(
controller.sendCustomCommand(testCommand, null), CONTROLLER_COMMAND_WAIT_TIME_MS);
assertThat(latch.await(0, TimeUnit.MILLISECONDS)).isTrue();
}
}
}
@LargeTest
@Test
public void setRewindIncrementMs_withPositiveRewindIncrement_rewinds() throws Exception {
int testResId = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz;
int testDuration = 10_000;
int tolerance = 100;
int testSeekPosition = 2_000;
int testRewindIncrementMs = 500;
TestUtils.loadResource(context, testResId, sessionPlayerConnector);
// seekTo() sometimes takes couple of seconds. Disable default timeout behavior.
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setRewindIncrementMs(testRewindIncrementMs)
.setSeekTimeoutMs(0)
.build())) {
try (MediaController controller = createConnectedController(session)) {
// Prepare first to ensure that seek() works.
assertSessionResultSuccess(
controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getDuration())
.isWithin(tolerance)
.of(testDuration);
assertSessionResultSuccess(
controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getCurrentPosition())
.isWithin(tolerance)
.of(testSeekPosition);
// Test rewind
assertSessionResultSuccess(
controller.rewind(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getCurrentPosition())
.isWithin(tolerance)
.of(testSeekPosition - testRewindIncrementMs);
}
}
}
@LargeTest
@Test
public void setFastForwardIncrementMs_withPositiveFastForwardIncrement_fastsForward()
throws Exception {
int testResId = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz;
int testDuration = 10_000;
int tolerance = 100;
int testSeekPosition = 2_000;
int testFastForwardIncrementMs = 300;
TestUtils.loadResource(context, testResId, sessionPlayerConnector);
// seekTo() sometimes takes couple of seconds. Disable default timeout behavior.
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setFastForwardIncrementMs(testFastForwardIncrementMs)
.setSeekTimeoutMs(0)
.build())) {
try (MediaController controller = createConnectedController(session)) {
// Prepare first to ensure that seek() works.
assertSessionResultSuccess(
controller.prepare(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getDuration())
.isWithin(tolerance)
.of(testDuration);
assertSessionResultSuccess(
controller.seekTo(testSeekPosition), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getCurrentPosition())
.isWithin(tolerance)
.of(testSeekPosition);
// Test fast-forward
assertSessionResultSuccess(
controller.fastForward(), PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat((float) sessionPlayerConnector.getCurrentPosition())
.isWithin(tolerance)
.of(testSeekPosition + testFastForwardIncrementMs);
}
}
}
@Test
public void setMediaItemProvider_withMediaItemProvider_receivesOnCreateMediaItem()
throws Exception {
int testResId = R.raw.testmp3_2;
Uri testMediaIdUri = TestUtils.createResourceUri(context, testResId);
CountDownLatch providerLatch = new CountDownLatch(1);
SessionCallbackBuilder.DefaultMediaItemProvider defaultMediaItemProvider =
new SessionCallbackBuilder.DefaultMediaItemProvider();
SessionCallbackBuilder.MediaItemProvider provider =
(session, controllerInfo, mediaId) -> {
assertThat(mediaId).isEqualTo(testMediaIdUri.toString());
providerLatch.countDown();
return defaultMediaItemProvider.onCreateMediaItem(session, controllerInfo, mediaId);
};
CountDownLatch currentMediaItemChangedLatch = new CountDownLatch(1);
sessionPlayerConnector.registerPlayerCallback(
executor,
new SessionPlayer.PlayerCallback() {
@Override
public void onCurrentMediaItemChanged(
@NonNull SessionPlayer player, @NonNull MediaItem item) {
assertThat(((UriMediaItem) item).getUri()).isEqualTo(testMediaIdUri);
currentMediaItemChangedLatch.countDown();
}
});
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setMediaItemProvider(provider)
.build())) {
try (MediaController controller = createConnectedController(session)) {
assertSessionResultSuccess(
controller.setMediaItem(testMediaIdUri.toString()),
PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS);
assertThat(providerLatch.await(0, TimeUnit.MILLISECONDS)).isTrue();
assertThat(
currentMediaItemChangedLatch.await(
CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
}
}
}
@Test
public void setSkipCallback_withSkipBackward_receivesOnSkipBackward() throws Exception {
CountDownLatch skipBackwardCalledLatch = new CountDownLatch(1);
SessionCallbackBuilder.SkipCallback skipCallback =
new SessionCallbackBuilder.SkipCallback() {
@Override
public int onSkipBackward(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
skipBackwardCalledLatch.countDown();
return SessionResult.RESULT_SUCCESS;
}
@Override
public int onSkipForward(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
};
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setSkipCallback(skipCallback)
.build())) {
try (MediaController controller = createConnectedController(session)) {
assertSessionResultSuccess(controller.skipBackward(), CONTROLLER_COMMAND_WAIT_TIME_MS);
assertThat(skipBackwardCalledLatch.await(0, TimeUnit.MILLISECONDS)).isTrue();
}
}
}
@Test
public void setSkipCallback_withSkipForward_receivesOnSkipForward() throws Exception {
CountDownLatch skipForwardCalledLatch = new CountDownLatch(1);
SessionCallbackBuilder.SkipCallback skipCallback =
new SessionCallbackBuilder.SkipCallback() {
@Override
public int onSkipBackward(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
@Override
public int onSkipForward(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
skipForwardCalledLatch.countDown();
return SessionResult.RESULT_SUCCESS;
}
};
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setSkipCallback(skipCallback)
.build())) {
try (MediaController controller = createConnectedController(session)) {
assertSessionResultSuccess(controller.skipForward(), CONTROLLER_COMMAND_WAIT_TIME_MS);
assertThat(skipForwardCalledLatch.await(0, TimeUnit.MILLISECONDS)).isTrue();
}
}
}
@Test
public void setPostConnectCallback_afterConnect_receivesOnPostConnect() throws Exception {
CountDownLatch postConnectLatch = new CountDownLatch(1);
SessionCallbackBuilder.PostConnectCallback postConnectCallback =
(session, controllerInfo) -> postConnectLatch.countDown();
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setPostConnectCallback(postConnectCallback)
.build())) {
try (MediaController controller = createConnectedController(session)) {
assertThat(postConnectLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
}
}
}
@Test
public void setDisconnectedCallback_afterDisconnect_receivesOnDisconnected() throws Exception {
CountDownLatch disconnectedLatch = new CountDownLatch(1);
SessionCallbackBuilder.DisconnectedCallback disconnectCallback =
(session, controllerInfo) -> disconnectedLatch.countDown();
try (MediaSession session =
createMediaSession(
sessionPlayerConnector,
new SessionCallbackBuilder(context, sessionPlayerConnector)
.setDisconnectedCallback(disconnectCallback)
.build())) {
try (MediaController controller = createConnectedController(session)) {}
assertThat(disconnectedLatch.await(CONTROLLER_COMMAND_WAIT_TIME_MS, TimeUnit.MILLISECONDS))
.isTrue();
}
}
private MediaSession createMediaSession(
SessionPlayer sessionPlayer, MediaSession.SessionCallback callback) {
return new MediaSession.Builder(context, sessionPlayer)
.setSessionCallback(executor, callback)
.setId(MEDIA_SESSION_ID)
.build();
}
private MediaController createConnectedController(MediaSession session) throws Exception {
return createConnectedController(session, null, null);
}
private MediaController createConnectedController(
MediaSession session,
OnConnectedListener onConnectedListener,
OnAllowedCommandsChangedListener onAllowedCommandsChangedListener)
throws Exception {
CountDownLatch latch = new CountDownLatch(1);
MediaController.ControllerCallback callback =
new MediaController.ControllerCallback() {
@Override
public void onAllowedCommandsChanged(
@NonNull MediaController controller, @NonNull SessionCommandGroup commands) {
if (onAllowedCommandsChangedListener != null) {
onAllowedCommandsChangedListener.onAllowedCommandsChanged(controller, commands);
}
}
@Override
public void onConnected(
@NonNull MediaController controller, @NonNull SessionCommandGroup allowedCommands) {
if (onConnectedListener != null) {
onConnectedListener.onConnected(controller, allowedCommands);
}
latch.countDown();
}
};
MediaController controller =
new MediaController.Builder(context)
.setSessionToken(session.getToken())
.setControllerCallback(ContextCompat.getMainExecutor(context), callback)
.build();
latch.await();
return controller;
}
private static void assertSessionResultSuccess(Future<SessionResult> future) throws Exception {
assertSessionResultSuccess(future, CONTROLLER_COMMAND_WAIT_TIME_MS);
}
private static void assertSessionResultSuccess(Future<SessionResult> future, long timeoutMs)
throws Exception {
SessionResult result = future.get(timeoutMs, TimeUnit.MILLISECONDS);
assertThat(result.getResultCode()).isEqualTo(SessionResult.RESULT_SUCCESS);
}
private static void assertSessionResultFailure(Future<SessionResult> future) throws Exception {
SessionResult result =
future.get(PLAYER_STATE_CHANGE_OVER_SESSION_WAIT_TIME_MS, TimeUnit.MILLISECONDS);
assertThat(result.getResultCode()).isNotEqualTo(SessionResult.RESULT_SUCCESS);
}
private static void assertAllowedCommands(
List<Integer> expectedAllowedCommandsCode, SessionCommandGroup allowedCommands) {
for (int commandCode : expectedAllowedCommandsCode) {
assertWithMessage("Command should be allowed, code=" + commandCode)
.that(allowedCommands.hasCommand(commandCode))
.isTrue();
}
}
private static void assertDisallowedCommands(
List<Integer> expectedDisallowedCommandsCode, SessionCommandGroup allowedCommands) {
for (int commandCode : expectedDisallowedCommandsCode) {
assertWithMessage("Command shouldn't be allowed, code=" + commandCode)
.that(allowedCommands.hasCommand(commandCode))
.isFalse();
}
}
private interface OnAllowedCommandsChangedListener {
void onAllowedCommandsChanged(MediaController controller, SessionCommandGroup allowedCommands);
}
private interface OnConnectedListener {
void onConnected(MediaController controller, SessionCommandGroup allowedCommands);
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.content.res.AssetFileDescriptor;
import android.util.Log;
import androidx.media2.common.DataSourceCallback;
import java.io.IOException;
import java.io.InputStream;
/** A DataSourceCallback that reads from a byte array for use in tests. */
public class TestDataSourceCallback extends DataSourceCallback {
private static final String TAG = "TestDataSourceCallback";
private byte[] data;
// Read an asset fd into a new byte array media item. Closes afd.
public static TestDataSourceCallback fromAssetFd(AssetFileDescriptor afd) throws IOException {
try {
InputStream in = afd.createInputStream();
int size = (int) afd.getDeclaredLength();
byte[] data = new byte[size];
int writeIndex = 0;
int numRead;
do {
numRead = in.read(data, writeIndex, size - writeIndex);
writeIndex += numRead;
} while (numRead >= 0);
return new TestDataSourceCallback(data);
} finally {
afd.close();
}
}
public TestDataSourceCallback(byte[] data) {
this.data = data;
}
@Override
public synchronized int readAt(long position, byte[] buffer, int offset, int size) {
// Clamp reads past the end of the source.
if (position >= data.length) {
return -1; // -1 indicates EOF
}
if (position + size > data.length) {
size -= (position + size) - data.length;
}
System.arraycopy(data, (int) position, buffer, offset, size);
return size;
}
@Override
public synchronized long getSize() {
Log.v(TAG, "getSize: " + data.length);
return data.length;
}
// Note: it's fine to keep using this media item after closing it.
@Override
public synchronized void close() {
Log.v(TAG, "close()");
}
}

View File

@ -0,0 +1,95 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS;
import static com.google.common.truth.Truth.assertThat;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Resources;
import android.net.Uri;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.SessionPlayer.PlayerResult;
import androidx.media2.common.UriMediaItem;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/** Utilities for tests. */
public final class TestUtils {
private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000;
public static Uri createResourceUri(Context context, int resId) {
Resources resources = context.getResources();
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(resources.getResourcePackageName(resId))
.appendPath(resources.getResourceTypeName(resId))
.appendPath(resources.getResourceEntryName(resId))
.build();
}
public static MediaItem createMediaItem(Context context) {
return createMediaItem(context, com.google.android.exoplayer2.ext.media2.test.R.raw.testvideo);
}
public static MediaItem createMediaItem(Context context, int resId) {
Uri testVideoUri = createResourceUri(context, resId);
String resourceName = context.getResources().getResourceName(resId);
MediaMetadata metadata =
new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, resourceName)
.build();
return new UriMediaItem.Builder(testVideoUri).setMetadata(metadata).build();
}
public static List<MediaItem> createPlaylist(Context context, int size) {
List<MediaItem> items = new ArrayList<>();
for (int i = 0; i < size; ++i) {
items.add(createMediaItem(context));
}
return items;
}
public static void loadResource(Context context, int resId, SessionPlayer sessionPlayer)
throws Exception {
Uri testUri = TestUtils.createResourceUri(context, resId);
MediaItem mediaItem = createMediaItem(context, resId);
assertPlayerResultSuccess(sessionPlayer.setMediaItem(mediaItem));
}
public static void assertPlayerResultSuccess(Future<PlayerResult> future) throws Exception {
assertPlayerResult(future, RESULT_SUCCESS);
}
public static void assertPlayerResult(
Future<PlayerResult> future, /* @PlayerResult.ResultCode */ int playerResult)
throws Exception {
assertThat(future).isNotNull();
PlayerResult result = future.get(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS);
assertThat(result).isNotNull();
assertThat(result.getResultCode()).isEqualTo(playerResult);
}
private TestUtils() {
// Prevent from instantiation.
}
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2019 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:keepScreenOn="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
<SurfaceView android:id="@+id/surface"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1">
</SurfaceView>
<SurfaceView android:id="@+id/surface2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1">
</SurfaceView>
<SurfaceView android:id="@+id/surface3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1">
</SurfaceView>
</LinearLayout>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2019 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.
-->
<manifest package="com.google.android.exoplayer2.ext.media2"/>

View File

@ -0,0 +1,47 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.PlaybackPreparer;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.util.Assertions;
/** Prepares an {@link ExoPlayer} instance with a {@link ConcatenatingMediaSource}. */
public final class ConcatenatingMediaSourcePlaybackPreparer implements PlaybackPreparer {
private final ExoPlayer exoPlayer;
private final ConcatenatingMediaSource concatenatingMediaSource;
/**
* Creates a concatenating media source playback preparer.
*
* @param exoPlayer The player to prepare.
* @param concatenatingMediaSource The concatenating media source with which to prepare the
* player.
*/
public ConcatenatingMediaSourcePlaybackPreparer(
ExoPlayer exoPlayer, ConcatenatingMediaSource concatenatingMediaSource) {
this.exoPlayer = exoPlayer;
this.concatenatingMediaSource = Assertions.checkNotNull(concatenatingMediaSource);
}
@Override
public void preparePlayback() {
exoPlayer.prepare(concatenatingMediaSource);
}
}

View File

@ -0,0 +1,110 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media2.common.DataSourceCallback;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.Assertions;
import java.io.EOFException;
import java.io.IOException;
/** An ExoPlayer {@link DataSource} for reading from a {@link DataSourceCallback}. */
/* package */ final class DataSourceCallbackDataSource extends BaseDataSource {
/**
* Returns a factory for {@link DataSourceCallbackDataSource}s.
*
* @return A factory for data sources that read from the data source callback.
*/
public static DataSource.Factory getFactory(DataSourceCallback dataSourceCallback) {
Assertions.checkNotNull(dataSourceCallback);
return () -> new DataSourceCallbackDataSource(dataSourceCallback);
}
private final DataSourceCallback dataSourceCallback;
@Nullable private Uri uri;
private long position;
private long bytesRemaining;
private boolean opened;
public DataSourceCallbackDataSource(DataSourceCallback dataSourceCallback) {
super(/* isNetwork= */ false);
this.dataSourceCallback = Assertions.checkNotNull(dataSourceCallback);
}
@Override
public long open(DataSpec dataSpec) throws IOException {
uri = dataSpec.uri;
position = dataSpec.position;
transferInitializing(dataSpec);
long dataSourceCallbackSize = dataSourceCallback.getSize();
if (dataSpec.length != C.LENGTH_UNSET) {
bytesRemaining = dataSpec.length;
} else if (dataSourceCallbackSize != -1) {
bytesRemaining = dataSourceCallbackSize - position;
} else {
bytesRemaining = C.LENGTH_UNSET;
}
opened = true;
transferStarted(dataSpec);
return bytesRemaining;
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws IOException {
if (readLength == 0) {
return 0;
} else if (bytesRemaining == 0) {
return C.RESULT_END_OF_INPUT;
}
int bytesToRead =
bytesRemaining == C.LENGTH_UNSET ? readLength : (int) Math.min(bytesRemaining, readLength);
int bytesRead = dataSourceCallback.readAt(position, buffer, offset, bytesToRead);
if (bytesRead == -1) {
if (bytesRemaining != C.LENGTH_UNSET) {
throw new EOFException();
}
return C.RESULT_END_OF_INPUT;
}
position += bytesRead;
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
bytesTransferred(bytesRead);
return bytesRead;
}
@Override
@Nullable
public Uri getUri() {
return uri;
}
@Override
public void close() {
uri = null;
if (opened) {
opened = false;
transferEnded();
}
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright 2020 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 com.google.android.exoplayer2.ext.media2;
import android.annotation.SuppressLint;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.media2.session.MediaSession;
/** Utility methods to use {@link MediaSession} with other existing Exo modules. */
public final class MediaSessionUtil {
/** Gets the {@link MediaSessionCompat.Token} from the {@link MediaSession}. */
// TODO(b/152764014): Deprecate this API when MediaSession#getSessionCompatToken() is released.
public static MediaSessionCompat.Token getSessionCompatToken(MediaSession session2) {
@SuppressLint("RestrictedApi")
@SuppressWarnings("RestrictTo")
MediaSessionCompat sessionCompat = session2.getSessionCompat();
return sessionCompat.getSessionToken();
}
private MediaSessionUtil() {
// Prevent from instantiation.
}
}

View File

@ -0,0 +1,452 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import androidx.annotation.GuardedBy;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.SessionPlayer.PlayerResult;
import com.google.android.exoplayer2.util.Log;
import com.google.common.util.concurrent.ListenableFuture;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
/** Manages the queue of player actions and handles running them one by one. */
/* package */ class PlayerCommandQueue implements AutoCloseable {
private static final String TAG = "PlayerCommandQueue";
private static final boolean DEBUG = false;
// Redefine command codes rather than using constants from SessionCommand here, because command
// code for setAudioAttribute() is missing in SessionCommand.
/** Command code for {@link SessionPlayer#setAudioAttributes}. */
public static final int COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES = 0;
/** Command code for {@link SessionPlayer#play} */
public static final int COMMAND_CODE_PLAYER_PLAY = 1;
/** Command code for {@link SessionPlayer#replacePlaylistItem(int, MediaItem)} */
public static final int COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM = 2;
/** Command code for {@link SessionPlayer#skipToPreviousPlaylistItem()} */
public static final int COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM = 3;
/** Command code for {@link SessionPlayer#skipToNextPlaylistItem()} */
public static final int COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM = 4;
/** Command code for {@link SessionPlayer#skipToPlaylistItem(int)} */
public static final int COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM = 5;
/** Command code for {@link SessionPlayer#updatePlaylistMetadata(MediaMetadata)} */
public static final int COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA = 6;
/** Command code for {@link SessionPlayer#setRepeatMode(int)} */
public static final int COMMAND_CODE_PLAYER_SET_REPEAT_MODE = 7;
/** Command code for {@link SessionPlayer#setShuffleMode(int)} */
public static final int COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE = 8;
/** Command code for {@link SessionPlayer#setMediaItem(MediaItem)} */
public static final int COMMAND_CODE_PLAYER_SET_MEDIA_ITEM = 9;
/** Command code for {@link SessionPlayer#seekTo(long)} */
public static final int COMMAND_CODE_PLAYER_SEEK_TO = 10;
/** Command code for {@link SessionPlayer#prepare()} */
public static final int COMMAND_CODE_PLAYER_PREPARE = 11;
/** Command code for {@link SessionPlayer#setPlaybackSpeed(float)} */
public static final int COMMAND_CODE_PLAYER_SET_SPEED = 12;
/** Command code for {@link SessionPlayer#pause()} */
public static final int COMMAND_CODE_PLAYER_PAUSE = 13;
/** Command code for {@link SessionPlayer#setPlaylist(List, MediaMetadata)} */
public static final int COMMAND_CODE_PLAYER_SET_PLAYLIST = 14;
/** Command code for {@link SessionPlayer#addPlaylistItem(int, MediaItem)} */
public static final int COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM = 15;
/** Command code for {@link SessionPlayer#removePlaylistItem(int)} */
public static final int COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM = 16;
/** List of session commands whose result would be set after the command is finished. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef(
value = {
COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES,
COMMAND_CODE_PLAYER_PLAY,
COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM,
COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA,
COMMAND_CODE_PLAYER_SET_REPEAT_MODE,
COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE,
COMMAND_CODE_PLAYER_SET_MEDIA_ITEM,
COMMAND_CODE_PLAYER_SEEK_TO,
COMMAND_CODE_PLAYER_PREPARE,
COMMAND_CODE_PLAYER_SET_SPEED,
COMMAND_CODE_PLAYER_PAUSE,
COMMAND_CODE_PLAYER_SET_PLAYLIST,
COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM,
COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM,
})
public @interface CommandCode {}
/** Command whose result would be set later via listener after the command is finished. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef(
value = {COMMAND_CODE_PLAYER_PREPARE, COMMAND_CODE_PLAYER_PLAY, COMMAND_CODE_PLAYER_PAUSE})
public @interface AsyncCommandCode {}
// Should be only used on the handler.
private final PlayerWrapper player;
private final PlayerHandler handler;
private final Object lock;
@GuardedBy("lock")
private final Deque<PlayerCommand> pendingPlayerCommandQueue;
@GuardedBy("lock")
private boolean closed;
// Should be only used on the handler.
@Nullable private AsyncPlayerCommandResult pendingAsyncPlayerCommandResult;
public PlayerCommandQueue(PlayerWrapper player, PlayerHandler handler) {
this.player = player;
this.handler = handler;
lock = new Object();
pendingPlayerCommandQueue = new ArrayDeque<>();
}
@Override
public void close() {
synchronized (lock) {
if (closed) {
return;
}
closed = true;
}
reset();
}
public void reset() {
handler.removeCallbacksAndMessages(/* token= */ null);
List<PlayerCommand> queue;
synchronized (lock) {
queue = new ArrayList<>(pendingPlayerCommandQueue);
pendingPlayerCommandQueue.clear();
}
for (PlayerCommand playerCommand : queue) {
playerCommand.result.set(
new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, /* item= */ null));
}
}
public ListenableFuture<PlayerResult> addCommand(
@CommandCode int commandCode, Callable<Boolean> command) {
return addCommand(commandCode, command, /* tag= */ null);
}
public ListenableFuture<PlayerResult> addCommand(
@CommandCode int commandCode, Callable<Boolean> command, @Nullable Object tag) {
SettableFuture<PlayerResult> result = SettableFuture.create();
synchronized (lock) {
if (closed) {
// OK to set result with lock hold because developers cannot add listener here.
result.set(new PlayerResult(PlayerResult.RESULT_ERROR_INVALID_STATE, /* item= */ null));
return result;
}
PlayerCommand playerCommand = new PlayerCommand(commandCode, command, result, tag);
result.addListener(
() -> {
if (result.isCancelled()) {
boolean isCommandPending;
synchronized (lock) {
isCommandPending = pendingPlayerCommandQueue.remove(playerCommand);
}
if (isCommandPending) {
result.set(
new PlayerResult(
PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem()));
if (DEBUG) {
Log.d(TAG, "canceled " + playerCommand);
}
}
if (pendingAsyncPlayerCommandResult != null
&& pendingAsyncPlayerCommandResult.result == result) {
pendingAsyncPlayerCommandResult = null;
}
}
processPendingCommandOnHandler();
},
handler::postOrRun);
if (DEBUG) {
Log.d(TAG, "adding " + playerCommand);
}
pendingPlayerCommandQueue.add(playerCommand);
}
processPendingCommand();
return result;
}
public void notifyCommandError() {
handler.postOrRun(
() -> {
@Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult;
if (pendingResult == null) {
if (DEBUG) {
Log.d(TAG, "Ignoring notifyCommandError(). No pending async command.");
}
return;
}
pendingResult.result.set(
new PlayerResult(PlayerResult.RESULT_ERROR_UNKNOWN, player.getCurrentMediaItem()));
pendingAsyncPlayerCommandResult = null;
if (DEBUG) {
Log.d(TAG, "error on " + pendingResult);
}
processPendingCommandOnHandler();
});
}
public void notifyCommandCompleted(@AsyncCommandCode int completedCommandCode) {
if (DEBUG) {
Log.d(TAG, "notifyCommandCompleted, completedCommandCode=" + completedCommandCode);
}
handler.postOrRun(
() -> {
@Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult;
if (pendingResult == null || pendingResult.commandCode != completedCommandCode) {
if (DEBUG) {
Log.d(
TAG,
"Unexpected Listener is notified from the Player. Player may be used"
+ " directly rather than "
+ toLogFriendlyString(completedCommandCode));
}
return;
}
pendingResult.result.set(
new PlayerResult(PlayerResult.RESULT_SUCCESS, player.getCurrentMediaItem()));
pendingAsyncPlayerCommandResult = null;
if (DEBUG) {
Log.d(TAG, "completed " + pendingResult);
}
processPendingCommandOnHandler();
});
}
private void processPendingCommand() {
handler.postOrRun(this::processPendingCommandOnHandler);
}
private void processPendingCommandOnHandler() {
while (pendingAsyncPlayerCommandResult == null) {
@Nullable PlayerCommand playerCommand;
synchronized (lock) {
playerCommand = pendingPlayerCommandQueue.poll();
}
if (playerCommand == null) {
return;
}
int commandCode = playerCommand.commandCode;
// Check if it's @AsyncCommandCode
boolean asyncCommand = isAsyncCommand(playerCommand.commandCode);
// Continuous COMMAND_CODE_PLAYER_SEEK_TO can be skipped.
if (commandCode == COMMAND_CODE_PLAYER_SEEK_TO) {
@Nullable List<PlayerCommand> skippingCommands = null;
while (true) {
synchronized (lock) {
@Nullable PlayerCommand pendingCommand = pendingPlayerCommandQueue.peek();
if (pendingCommand == null || pendingCommand.commandCode != commandCode) {
break;
}
pendingPlayerCommandQueue.poll();
if (skippingCommands == null) {
skippingCommands = new ArrayList<>();
}
skippingCommands.add(playerCommand);
playerCommand = pendingCommand;
}
}
if (skippingCommands != null) {
for (PlayerCommand skippingCommand : skippingCommands) {
skippingCommand.result.set(
new PlayerResult(PlayerResult.RESULT_INFO_SKIPPED, player.getCurrentMediaItem()));
if (DEBUG) {
Log.d(TAG, "skipping pending command, " + skippingCommand);
}
}
}
}
if (asyncCommand) {
// Result would come later, via #notifyCommandCompleted().
// Set pending player result first because it may be notified while the command is running.
pendingAsyncPlayerCommandResult =
new AsyncPlayerCommandResult(commandCode, playerCommand.result);
}
if (DEBUG) {
Log.d(TAG, "start processing command, " + playerCommand);
}
int resultCode;
if (player.hasError()) {
resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE;
} else {
try {
boolean handled = playerCommand.command.call();
resultCode = handled ? PlayerResult.RESULT_SUCCESS : PlayerResult.RESULT_INFO_SKIPPED;
} catch (IllegalStateException e) {
resultCode = PlayerResult.RESULT_ERROR_INVALID_STATE;
} catch (IllegalArgumentException | IndexOutOfBoundsException e) {
resultCode = PlayerResult.RESULT_ERROR_BAD_VALUE;
} catch (SecurityException e) {
resultCode = PlayerResult.RESULT_ERROR_PERMISSION_DENIED;
} catch (Exception e) {
resultCode = PlayerResult.RESULT_ERROR_UNKNOWN;
}
}
if (DEBUG) {
Log.d(TAG, "command processed, " + playerCommand);
}
if (asyncCommand) {
if (resultCode != PlayerResult.RESULT_SUCCESS
&& pendingAsyncPlayerCommandResult != null
&& playerCommand.result == pendingAsyncPlayerCommandResult.result) {
pendingAsyncPlayerCommandResult = null;
playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem()));
}
} else {
playerCommand.result.set(new PlayerResult(resultCode, player.getCurrentMediaItem()));
}
}
}
private static String toLogFriendlyString(@AsyncCommandCode int commandCode) {
switch (commandCode) {
case COMMAND_CODE_PLAYER_PLAY:
return "SessionPlayerConnector#play()";
case COMMAND_CODE_PLAYER_PAUSE:
return "SessionPlayerConnector#pause()";
case COMMAND_CODE_PLAYER_PREPARE:
return "SessionPlayerConnector#prepare()";
default:
// Never happens.
throw new IllegalStateException();
}
}
private static boolean isAsyncCommand(@CommandCode int commandCode) {
switch (commandCode) {
case COMMAND_CODE_PLAYER_PLAY:
case COMMAND_CODE_PLAYER_PAUSE:
case COMMAND_CODE_PLAYER_PREPARE:
return true;
}
return false;
}
private static final class AsyncPlayerCommandResult {
@AsyncCommandCode public final int commandCode;
public final SettableFuture<PlayerResult> result;
public AsyncPlayerCommandResult(
@AsyncCommandCode int commandCode, SettableFuture<PlayerResult> result) {
this.commandCode = commandCode;
this.result = result;
}
@Override
public String toString() {
StringBuilder stringBuilder =
new StringBuilder("AsyncPlayerCommandResult {commandCode=")
.append(commandCode)
.append(", result=")
.append(result.hashCode());
if (result.isDone()) {
try {
int resultCode = result.get(/* timeout= */ 0, TimeUnit.MILLISECONDS).getResultCode();
stringBuilder.append(", resultCode=").append(resultCode);
} catch (Exception e) {
// pass-through.
}
}
stringBuilder.append("}");
return stringBuilder.toString();
}
}
private static final class PlayerCommand {
public final int commandCode;
public final Callable<Boolean> command;
// Result shouldn't be set with lock held, because it may trigger listener set by developers.
public final SettableFuture<PlayerResult> result;
@Nullable private final Object tag;
public PlayerCommand(
int commandCode,
Callable<Boolean> command,
SettableFuture<PlayerResult> result,
@Nullable Object tag) {
this.commandCode = commandCode;
this.command = command;
this.result = result;
this.tag = tag;
}
@Override
public String toString() {
StringBuilder stringBuilder =
new StringBuilder("PlayerCommand {commandCode=")
.append(commandCode)
.append(", result=")
.append(result.hashCode());
if (result.isDone()) {
try {
int resultCode = result.get(/* timeout= */ 0, TimeUnit.MILLISECONDS).getResultCode();
stringBuilder.append(", resultCode=").append(resultCode);
} catch (Exception e) {
// pass-through.
}
}
if (tag != null) {
stringBuilder.append(", tag=").append(tag);
}
stringBuilder.append("}");
return stringBuilder.toString();
}
}
}

View File

@ -0,0 +1,41 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.os.Handler;
import android.os.Looper;
/** A {@link Handler} that provides {@link #postOrRun(Runnable)}. */
/* package */ final class PlayerHandler extends Handler {
public PlayerHandler(Looper looper) {
super(looper);
}
/**
* Posts the {@link Runnable} if the calling thread differs with the {@link Looper} of this
* handler. Otherwise, runs the runnable directly.
*
* @param r A runnable to either post or run.
* @return {@code true} if it's successfully run. {@code false} otherwise.
*/
public boolean postOrRun(Runnable r) {
if (Thread.currentThread() != getLooper().getThread()) {
return post(r);
}
r.run();
return true;
}
}

View File

@ -0,0 +1,533 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.media.AudioAttributesCompat;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.PlaybackPreparer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.audio.AudioListener;
import com.google.android.exoplayer2.util.Assertions;
import java.util.List;
/**
* Wraps an ExoPlayer {@link Player} instance and provides methods and notifies events like those in
* the {@link SessionPlayer} API.
*/
/* package */ final class PlayerWrapper {
/** Listener for player wrapper events. */
public interface Listener {
/**
* Called when the player state is changed.
*
* <p>This method will be called at first if multiple events should be notified at once.
*/
void onPlayerStateChanged(/* @SessionPlayer.PlayerState */ int playerState);
/** Called when the player is prepared. */
void onPrepared(MediaItem mediaItem, int bufferingPercentage);
/** Called when a seek request has completed. */
void onSeekCompleted();
/** Called when the player rebuffers. */
void onBufferingStarted(MediaItem mediaItem);
/** Called when the player becomes ready again after rebuffering. */
void onBufferingEnded(MediaItem mediaItem, int bufferingPercentage);
/** Called periodically with the player's buffered position as a percentage. */
void onBufferingUpdate(MediaItem mediaItem, int bufferingPercentage);
/** Called when current media item is changed. */
void onCurrentMediaItemChanged(MediaItem mediaItem);
/** Called when playback of the item list has ended. */
void onPlaybackEnded();
/** Called when the player encounters an error. */
void onError(@Nullable MediaItem mediaItem);
/** Called when the playlist is changed */
void onPlaylistChanged();
/** Called when the shuffle mode is changed */
void onShuffleModeChanged(int shuffleMode);
/** Called when the repeat mode is changed */
void onRepeatModeChanged(int repeatMode);
/** Called when the audio attributes is changed */
void onAudioAttributesChanged(AudioAttributesCompat audioAttributes);
/** Called when the playback speed is changed */
void onPlaybackSpeedChanged(float playbackSpeed);
}
private static final int POLL_BUFFER_INTERVAL_MS = 1000;
private final Listener listener;
private final PlayerHandler handler;
private final Runnable pollBufferRunnable;
private final Player player;
private final PlaylistManager playlistManager;
private final PlaybackPreparer playbackPreparer;
private final ControlDispatcher controlDispatcher;
private final ComponentListener componentListener;
private boolean prepared;
private boolean rebuffering;
private int currentWindowIndex;
/**
* Creates a new ExoPlayer wrapper.
*
* @param listener A listener for player wrapper events.
* @param player The player to handle commands
* @param playlistManager The playlist manager to handle playlist commands
* @param playbackPreparer The playback preparer to prepare
* @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
* changes to the player.
*/
PlayerWrapper(
Listener listener,
Player player,
PlaylistManager playlistManager,
PlaybackPreparer playbackPreparer,
ControlDispatcher controlDispatcher) {
this.listener = listener;
this.player = player;
this.playlistManager = playlistManager;
this.playbackPreparer = playbackPreparer;
this.controlDispatcher = controlDispatcher;
componentListener = new ComponentListener();
player.addListener(componentListener);
@Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
if (audioComponent != null) {
audioComponent.addAudioListener(componentListener);
}
handler = new PlayerHandler(player.getApplicationLooper());
pollBufferRunnable = new PollBufferRunnable();
currentWindowIndex = C.INDEX_UNSET;
}
public boolean setMediaItem(MediaItem mediaItem) {
boolean handled = playlistManager.setMediaItem(player, mediaItem);
if (handled) {
currentWindowIndex = playlistManager.getCurrentMediaItemIndex(player);
}
return handled;
}
public boolean setPlaylist(List<MediaItem> playlist, @Nullable MediaMetadata metadata) {
boolean handled = playlistManager.setPlaylist(player, playlist, metadata);
if (handled) {
currentWindowIndex = playlistManager.getCurrentMediaItemIndex(player);
}
return handled;
}
public boolean addPlaylistItem(int index, MediaItem item) {
return playlistManager.addPlaylistItem(player, index, item);
}
public boolean removePlaylistItem(@IntRange(from = 0) int index) {
return playlistManager.removePlaylistItem(player, index);
}
public boolean replacePlaylistItem(int index, MediaItem item) {
return playlistManager.replacePlaylistItem(player, index, item);
}
public boolean skipToPreviousPlaylistItem() {
return playlistManager.skipToPreviousPlaylistItem(player, controlDispatcher);
}
public boolean skipToNextPlaylistItem() {
return playlistManager.skipToNextPlaylistItem(player, controlDispatcher);
}
public boolean skipToPlaylistItem(@IntRange(from = 0) int index) {
return playlistManager.skipToPlaylistItem(player, controlDispatcher, index);
}
public boolean updatePlaylistMetadata(@Nullable MediaMetadata metadata) {
return playlistManager.updatePlaylistMetadata(player, metadata);
}
public boolean setRepeatMode(int repeatMode) {
return controlDispatcher.dispatchSetRepeatMode(
player, Utils.getExoPlayerRepeatMode(repeatMode));
}
public boolean setShuffleMode(int shuffleMode) {
return controlDispatcher.dispatchSetShuffleModeEnabled(
player, Utils.getExoPlayerShuffleMode(shuffleMode));
}
@Nullable
public List<MediaItem> getPlaylist() {
return playlistManager.getPlaylist(player);
}
@Nullable
public MediaMetadata getPlaylistMetadata() {
return playlistManager.getPlaylistMetadata(player);
}
public int getRepeatMode() {
return Utils.getRepeatMode(player.getRepeatMode());
}
public int getShuffleMode() {
return Utils.getShuffleMode(player.getShuffleModeEnabled());
}
public int getCurrentMediaItemIndex() {
return playlistManager.getCurrentMediaItemIndex(player);
}
public int getPreviousMediaItemIndex() {
return player.getPreviousWindowIndex();
}
public int getNextMediaItemIndex() {
return player.getNextWindowIndex();
}
@Nullable
public MediaItem getCurrentMediaItem() {
return playlistManager.getCurrentMediaItem(player);
}
public boolean prepare() {
if (prepared) {
return false;
}
playbackPreparer.preparePlayback();
return true;
}
public boolean play() {
if (player.getPlaybackState() == Player.STATE_ENDED) {
int currentWindowIndex = playlistManager.getCurrentMediaItemIndex(player);
boolean seekHandled =
controlDispatcher.dispatchSeekTo(player, currentWindowIndex, /* positionMs= */ 0);
if (!seekHandled) {
return false;
}
}
boolean playWhenReady = player.getPlayWhenReady();
int suppressReason = player.getPlaybackSuppressionReason();
if (playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
return false;
}
return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true);
}
public boolean pause() {
boolean playWhenReady = player.getPlayWhenReady();
int suppressReason = player.getPlaybackSuppressionReason();
if (!playWhenReady && suppressReason == Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
return false;
}
return controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false);
}
public boolean seekTo(long position) {
int currentWindowIndex = playlistManager.getCurrentMediaItemIndex(player);
return controlDispatcher.dispatchSeekTo(player, currentWindowIndex, position);
}
public long getCurrentPosition() {
Assertions.checkState(getState() != SessionPlayer.PLAYER_STATE_IDLE);
return Math.max(0, player.getCurrentPosition());
}
public long getDuration() {
Assertions.checkState(getState() != SessionPlayer.PLAYER_STATE_IDLE);
long duration = player.getDuration();
return duration == C.TIME_UNSET ? -1 : duration;
}
public long getBufferedPosition() {
Assertions.checkState(getState() != SessionPlayer.PLAYER_STATE_IDLE);
return player.getBufferedPosition();
}
/* @SessionPlayer.PlayerState */
private int getState() {
if (hasError()) {
return SessionPlayer.PLAYER_STATE_ERROR;
}
int state = player.getPlaybackState();
boolean playWhenReady = player.getPlayWhenReady();
switch (state) {
case Player.STATE_IDLE:
return SessionPlayer.PLAYER_STATE_IDLE;
case Player.STATE_ENDED:
return SessionPlayer.PLAYER_STATE_PAUSED;
case Player.STATE_BUFFERING:
case Player.STATE_READY:
return playWhenReady
? SessionPlayer.PLAYER_STATE_PLAYING
: SessionPlayer.PLAYER_STATE_PAUSED;
default:
throw new IllegalStateException();
}
}
public boolean setAudioAttributes(AudioAttributesCompat audioAttributes) {
Player.AudioComponent audioComponent = Assertions.checkStateNotNull(player.getAudioComponent());
audioComponent.setAudioAttributes(
Utils.getAudioAttributes(audioAttributes), /* handleAudioFocus= */ true);
return true;
}
public AudioAttributesCompat getAudioAttributes() {
@Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
return Utils.getAudioAttributesCompat(
audioComponent != null ? audioComponent.getAudioAttributes() : AudioAttributes.DEFAULT);
}
public boolean setPlaybackSpeed(float playbackSpeed) {
player.setPlaybackParameters(new PlaybackParameters(playbackSpeed));
return true;
}
public float getPlaybackSpeed() {
return player.getPlaybackParameters().speed;
}
public void reset() {
controlDispatcher.dispatchStop(player, /* reset= */ true);
prepared = false;
rebuffering = false;
}
public void close() {
handler.removeCallbacks(pollBufferRunnable);
player.removeListener(componentListener);
@Nullable Player.AudioComponent audioComponent = player.getAudioComponent();
if (audioComponent != null) {
audioComponent.removeAudioListener(componentListener);
}
}
public boolean isCurrentMediaItemSeekable() {
return getCurrentMediaItem() != null
&& !player.isPlayingAd()
&& player.isCurrentWindowSeekable();
}
public boolean canSkipToPlaylistItem() {
@Nullable List<MediaItem> playlist = getPlaylist();
return playlist != null && playlist.size() > 1;
}
public boolean canSkipToPreviousPlaylistItem() {
return player.hasPrevious();
}
public boolean canSkipToNextPlaylistItem() {
return player.hasNext();
}
public boolean hasError() {
return player.getPlayerError() != null;
}
private void handlePlayWhenReadyChanged() {
listener.onPlayerStateChanged(getState());
}
private void handlePlayerStateChanged(@Player.State int state) {
if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) {
handler.postOrRun(pollBufferRunnable);
} else {
handler.removeCallbacks(pollBufferRunnable);
}
switch (state) {
case Player.STATE_BUFFERING:
maybeNotifyBufferingEvents();
break;
case Player.STATE_READY:
maybeNotifyReadyEvents();
break;
case Player.STATE_ENDED:
maybeNotifyEndedEvents();
break;
case Player.STATE_IDLE:
// Do nothing.
break;
default:
throw new IllegalStateException();
}
}
private void handlePositionDiscontinuity(@Player.DiscontinuityReason int reason) {
int currentWindowIndex = playlistManager.getCurrentMediaItemIndex(player);
if (this.currentWindowIndex != currentWindowIndex) {
this.currentWindowIndex = currentWindowIndex;
MediaItem currentMediaItem =
Assertions.checkNotNull(playlistManager.getCurrentMediaItem(player));
listener.onCurrentMediaItemChanged(currentMediaItem);
} else {
listener.onSeekCompleted();
}
}
private void handlePlayerError() {
listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_ERROR);
listener.onError(getCurrentMediaItem());
}
private void handleRepeatModeChanged(@Player.RepeatMode int repeatMode) {
listener.onRepeatModeChanged(Utils.getRepeatMode(repeatMode));
}
private void handleShuffleMode(boolean shuffleModeEnabled) {
listener.onShuffleModeChanged(Utils.getShuffleMode(shuffleModeEnabled));
}
private void handlePlaybackParametersChanged(PlaybackParameters playbackParameters) {
listener.onPlaybackSpeedChanged(playbackParameters.speed);
}
private void handleTimelineChanged() {
playlistManager.onTimelineChanged(player);
listener.onPlaylistChanged();
}
private void handleAudioAttributesChanged(AudioAttributes audioAttributes) {
listener.onAudioAttributesChanged(Utils.getAudioAttributesCompat(audioAttributes));
}
private void updateBufferingAndScheduleNextPollBuffer() {
MediaItem mediaItem = Assertions.checkNotNull(getCurrentMediaItem());
listener.onBufferingUpdate(mediaItem, player.getBufferedPercentage());
handler.removeCallbacks(pollBufferRunnable);
handler.postDelayed(pollBufferRunnable, POLL_BUFFER_INTERVAL_MS);
}
private void maybeNotifyBufferingEvents() {
MediaItem mediaItem = Assertions.checkNotNull(getCurrentMediaItem());
if (prepared && !rebuffering) {
rebuffering = true;
listener.onBufferingStarted(mediaItem);
}
}
private void maybeNotifyReadyEvents() {
MediaItem mediaItem = Assertions.checkNotNull(getCurrentMediaItem());
boolean prepareComplete = !prepared;
if (prepareComplete) {
prepared = true;
handlePositionDiscontinuity(Player.DISCONTINUITY_REASON_PERIOD_TRANSITION);
listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED);
listener.onPrepared(mediaItem, player.getBufferedPercentage());
}
if (rebuffering) {
rebuffering = false;
listener.onBufferingEnded(mediaItem, player.getBufferedPercentage());
}
}
private void maybeNotifyEndedEvents() {
if (player.getPlayWhenReady()) {
listener.onPlayerStateChanged(SessionPlayer.PLAYER_STATE_PAUSED);
listener.onPlaybackEnded();
player.setPlayWhenReady(false);
}
}
private final class ComponentListener implements Player.EventListener, AudioListener {
// Player.EventListener implementation.
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
handlePlayWhenReadyChanged();
}
@Override
public void onPlaybackStateChanged(@Player.State int state) {
handlePlayerStateChanged(state);
}
@Override
public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) {
handlePositionDiscontinuity(reason);
}
@Override
public void onPlayerError(ExoPlaybackException error) {
handlePlayerError();
}
@Override
public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) {
handleRepeatModeChanged(repeatMode);
}
@Override
public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
handleShuffleMode(shuffleModeEnabled);
}
@Override
public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
handlePlaybackParametersChanged(playbackParameters);
}
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
handleTimelineChanged();
}
// AudioListener implementation.
@Override
public void onAudioAttributesChanged(AudioAttributes audioAttributes) {
handleAudioAttributesChanged(audioAttributes);
}
}
private final class PollBufferRunnable implements Runnable {
@Override
public void run() {
updateBufferingAndScheduleNextPollBuffer();
}
}
}

View File

@ -0,0 +1,156 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import androidx.annotation.Nullable;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer;
import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.Player;
import java.util.List;
/** Interface that handles playlist edit and navigation operations. */
public interface PlaylistManager {
/**
* See {@link SessionPlayer#setPlaylist(List, MediaMetadata)}.
*
* @param player The player used to build SessionPlayer together.
* @param playlist A list of {@link MediaItem} objects to set as a play list.
* @param metadata The metadata of the playlist.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean setPlaylist(Player player, List<MediaItem> playlist, @Nullable MediaMetadata metadata);
/**
* See {@link SessionPlayer#addPlaylistItem(int, MediaItem)}.
*
* @param player The player used to build SessionPlayer together.
* @param index The index of the item you want to add in the playlist.
* @param mediaItem The media item you want to add.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean addPlaylistItem(Player player, int index, MediaItem mediaItem);
/**
* See {@link SessionPlayer#removePlaylistItem(int)}.
*
* @param player The player used to build SessionPlayer together.
* @param index The index of the item you want to remove in the playlist.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean removePlaylistItem(Player player, int index);
/**
* See {@link SessionPlayer#replacePlaylistItem(int, MediaItem)}.
*
* @param player The player used to build SessionPlayer together.
* @param mediaItem The media item you want to replace with.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean replacePlaylistItem(Player player, int index, MediaItem mediaItem);
/**
* See {@link SessionPlayer#setMediaItem(MediaItem)}.
*
* @param player The player used to build SessionPlayer together.
* @param mediaItem The media item you want to set.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean setMediaItem(Player player, MediaItem mediaItem);
/**
* See {@link SessionPlayer#updatePlaylistMetadata(MediaMetadata)}.
*
* @param player The player used to build SessionPlayer together.
* @param metadata The metadata of the playlist.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean updatePlaylistMetadata(Player player, @Nullable MediaMetadata metadata);
/**
* See {@link SessionPlayer#skipToNextPlaylistItem()}.
*
* @param player The player used to build SessionPlayer together.
* @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
* changes to the player.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean skipToNextPlaylistItem(Player player, ControlDispatcher controlDispatcher);
/**
* See {@link SessionPlayer#skipToPreviousPlaylistItem()}.
*
* @param player The player used to build SessionPlayer together.
* @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
* changes to the player.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean skipToPreviousPlaylistItem(Player player, ControlDispatcher controlDispatcher);
/**
* See {@link SessionPlayer#skipToPlaylistItem(int)}.
*
* @param player The player used to build SessionPlayer together.
* @param controlDispatcher A {@link ControlDispatcher} that should be used for dispatching
* changes to the player.
* @return true if the operation was dispatched. False if suppressed.
*/
boolean skipToPlaylistItem(Player player, ControlDispatcher controlDispatcher, int index);
/**
* See {@link SessionPlayer#getCurrentMediaItemIndex()}.
*
* @param player The player used to build SessionPlayer together.
* @return The current media item index
*/
int getCurrentMediaItemIndex(Player player);
/**
* See {@link SessionPlayer#getCurrentMediaItem()}.
*
* @param player The player used to build SessionPlayer together.
* @return The current media item index
*/
@Nullable
MediaItem getCurrentMediaItem(Player player);
/**
* See {@link SessionPlayer#setPlaylist(List, MediaMetadata)}.
*
* @param player The player used to build SessionPlayer together.
* @return The playlist.
*/
@Nullable
List<MediaItem> getPlaylist(Player player);
/**
* See {@link SessionPlayer#getPlaylistMetadata()}.
*
* @param player The player used to build SessionPlayer together.
* @return The metadata of the playlist.
*/
@Nullable
MediaMetadata getPlaylistMetadata(Player player);
/**
* Called when the player's timeline is changed.
*
* @param player The player used to build SessionPlayer together.
*/
void onTimelineChanged(Player player);
}

View File

@ -0,0 +1,384 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.Rating;
import androidx.media2.common.SessionPlayer;
import androidx.media2.session.MediaSession;
import androidx.media2.session.SessionCommand;
import androidx.media2.session.SessionCommandGroup;
import androidx.media2.session.SessionResult;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.AllowedCommandProvider;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.CustomCommandProvider;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.DisconnectedCallback;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.MediaItemProvider;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.PostConnectCallback;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.RatingCallback;
import com.google.android.exoplayer2.ext.media2.SessionCallbackBuilder.SkipCallback;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/* package */ class SessionCallback extends MediaSession.SessionCallback {
private static final String TAG = "SessionCallback";
private final SessionPlayer sessionPlayer;
private final int fastForwardMs;
private final int rewindMs;
private final int seekTimeoutMs;
private final Set<MediaSession> sessions;
private final AllowedCommandProvider allowedCommandProvider;
@Nullable private final RatingCallback ratingCallback;
@Nullable private final CustomCommandProvider customCommandProvider;
@Nullable private final MediaItemProvider mediaItemProvider;
@Nullable private final SkipCallback skipCallback;
@Nullable private final PostConnectCallback postConnectCallback;
@Nullable private final DisconnectedCallback disconnectedCallback;
private boolean loggedUnexpectedSessionPlayerWarning;
public SessionCallback(
SessionPlayerConnector sessionPlayerConnector,
int fastForwardMs,
int rewindMs,
int seekTimeoutMs,
AllowedCommandProvider allowedCommandProvider,
@Nullable RatingCallback ratingCallback,
@Nullable CustomCommandProvider customCommandProvider,
@Nullable MediaItemProvider mediaItemProvider,
@Nullable SkipCallback skipCallback,
@Nullable PostConnectCallback postConnectCallback,
@Nullable DisconnectedCallback disconnectedCallback) {
this.sessionPlayer = sessionPlayerConnector;
this.allowedCommandProvider = allowedCommandProvider;
this.ratingCallback = ratingCallback;
this.customCommandProvider = customCommandProvider;
this.mediaItemProvider = mediaItemProvider;
this.skipCallback = skipCallback;
this.postConnectCallback = postConnectCallback;
this.disconnectedCallback = disconnectedCallback;
this.fastForwardMs = fastForwardMs;
this.rewindMs = rewindMs;
this.seekTimeoutMs = seekTimeoutMs;
this.sessions = Collections.newSetFromMap(new ConcurrentHashMap<>());
// Register PlayerCallback and make it to be called before the ListenableFuture set the result.
// It help the PlayerCallback to update allowed commands before pended Player APIs are executed.
sessionPlayerConnector.registerPlayerCallback(Runnable::run, new PlayerCallback());
}
@Override
@Nullable
public SessionCommandGroup onConnect(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
sessions.add(session);
if (!allowedCommandProvider.acceptConnection(session, controllerInfo)) {
return null;
}
SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controllerInfo);
return allowedCommandProvider.getAllowedCommands(session, controllerInfo, baseAllowedCommands);
}
@Override
public void onPostConnect(
@NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
if (postConnectCallback != null) {
postConnectCallback.onPostConnect(session, controller);
}
}
@Override
public void onDisconnected(MediaSession session, MediaSession.ControllerInfo controller) {
if (session.getConnectedControllers().isEmpty()) {
sessions.remove(session);
}
if (disconnectedCallback != null) {
disconnectedCallback.onDisconnected(session, controller);
}
}
@Override
public int onCommandRequest(
MediaSession session, MediaSession.ControllerInfo controller, SessionCommand command) {
return allowedCommandProvider.onCommandRequest(session, controller, command);
}
@Override
@Nullable
public MediaItem onCreateMediaItem(
MediaSession session, MediaSession.ControllerInfo controller, String mediaId) {
Assertions.checkNotNull(mediaItemProvider);
return mediaItemProvider.onCreateMediaItem(session, controller, mediaId);
}
@Override
public int onSetRating(
MediaSession session, MediaSession.ControllerInfo controller, String mediaId, Rating rating) {
if (ratingCallback != null) {
return ratingCallback.onSetRating(session, controller, mediaId, rating);
}
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
@Override
public SessionResult onCustomCommand(
MediaSession session,
MediaSession.ControllerInfo controller,
SessionCommand customCommand,
@Nullable Bundle args) {
if (customCommandProvider != null) {
return customCommandProvider.onCustomCommand(session, controller, customCommand, args);
}
return new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED, null);
}
@Override
public int onFastForward(MediaSession session, MediaSession.ControllerInfo controller) {
if (fastForwardMs > 0) {
return seekToOffset(fastForwardMs);
}
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
@Override
public int onRewind(MediaSession session, MediaSession.ControllerInfo controller) {
if (rewindMs > 0) {
return seekToOffset(-rewindMs);
}
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
@Override
public int onSkipBackward(
@NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
if (skipCallback != null) {
return skipCallback.onSkipBackward(session, controller);
}
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
@Override
public int onSkipForward(
@NonNull MediaSession session, @NonNull MediaSession.ControllerInfo controller) {
if (skipCallback != null) {
return skipCallback.onSkipForward(session, controller);
}
return SessionResult.RESULT_ERROR_NOT_SUPPORTED;
}
private int seekToOffset(long offsetMs) {
long positionMs = sessionPlayer.getCurrentPosition() + offsetMs;
long durationMs = sessionPlayer.getDuration();
if (durationMs != C.TIME_UNSET) {
positionMs = Math.min(positionMs, durationMs);
}
positionMs = Math.max(positionMs, 0);
ListenableFuture<SessionPlayer.PlayerResult> result = sessionPlayer.seekTo(positionMs);
try {
if (seekTimeoutMs <= 0) {
return result.get().getResultCode();
}
return result.get(seekTimeoutMs, TimeUnit.MILLISECONDS).getResultCode();
} catch (ExecutionException | InterruptedException | TimeoutException e) {
Log.w(TAG, "Failed to get the seeking result", e);
return SessionResult.RESULT_ERROR_UNKNOWN;
}
}
private SessionCommandGroup buildAllowedCommands(
MediaSession session, MediaSession.ControllerInfo controllerInfo) {
SessionCommandGroup.Builder build;
@Nullable
SessionCommandGroup commands =
(customCommandProvider != null)
? customCommandProvider.getCustomCommands(session, controllerInfo)
: null;
if (commands != null) {
build = new SessionCommandGroup.Builder(commands);
} else {
build = new SessionCommandGroup.Builder();
}
build.addAllPredefinedCommands(SessionCommand.COMMAND_VERSION_1);
// TODO: Use removeCommand(int) when it's added [Internal: b/142848015].
if (mediaItemProvider == null) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM));
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SET_PLAYLIST));
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM));
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM));
}
if (ratingCallback == null) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING));
}
if (skipCallback == null) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_BACKWARD));
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SKIP_FORWARD));
}
// Apply player's capability.
// Check whether the session has unexpectedly changed the player.
if (session.getPlayer() instanceof SessionPlayerConnector) {
SessionPlayerConnector sessionPlayerConnector = (SessionPlayerConnector) session.getPlayer();
// Check whether skipTo* works.
if (!sessionPlayerConnector.canSkipToPlaylistItem()) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
}
if (!sessionPlayerConnector.canSkipToPreviousPlaylistItem()) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
}
if (!sessionPlayerConnector.canSkipToNextPlaylistItem()) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
}
// Check whether seekTo/rewind/fastForward works.
if (!sessionPlayerConnector.isCurrentMediaItemSeekable()) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SEEK_TO));
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
} else {
if (fastForwardMs <= 0) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
}
if (rewindMs <= 0) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
}
}
} else {
if (!loggedUnexpectedSessionPlayerWarning) {
// This can happen if MediaSession#updatePlayer() is called.
Log.e(TAG, "SessionPlayer isn't a SessionPlayerConnector. Guess the allowed command.");
loggedUnexpectedSessionPlayerWarning = true;
}
if (fastForwardMs <= 0) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_FAST_FORWARD));
}
if (rewindMs <= 0) {
build.removeCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_REWIND));
}
@Nullable List<MediaItem> playlist = sessionPlayer.getPlaylist();
if (playlist == null) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
} else {
if (playlist.isEmpty()
&& (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE
|| sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) {
build.removeCommand(
new SessionCommand(
SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM));
}
if (playlist.size() == sessionPlayer.getCurrentMediaItemIndex() + 1
&& (sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_NONE
|| sessionPlayer.getRepeatMode() == SessionPlayer.REPEAT_MODE_ONE)) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM));
}
if (playlist.size() <= 1) {
build.removeCommand(
new SessionCommand(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM));
}
}
}
return build.build();
}
private static boolean isBufferedState(/* @SessionPlayer.BuffState */ int buffState) {
return buffState == SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE
|| buffState == SessionPlayer.BUFFERING_STATE_COMPLETE;
}
private final class PlayerCallback extends SessionPlayer.PlayerCallback {
private boolean currentMediaItemBuffered;
@Override
public void onPlaylistChanged(
SessionPlayer player, @Nullable List<MediaItem> list, @Nullable MediaMetadata metadata) {
updateAllowedCommands();
}
@Override
public void onPlayerStateChanged(SessionPlayer player, int playerState) {
updateAllowedCommands();
}
@Override
public void onRepeatModeChanged(SessionPlayer player, int repeatMode) {
updateAllowedCommands();
}
@Override
public void onShuffleModeChanged(SessionPlayer player, int shuffleMode) {
updateAllowedCommands();
}
@Override
public void onCurrentMediaItemChanged(SessionPlayer player, MediaItem item) {
currentMediaItemBuffered = isBufferedState(player.getBufferingState());
updateAllowedCommands();
}
@Override
public void onBufferingStateChanged(
SessionPlayer player, @Nullable MediaItem item, int buffState) {
if (currentMediaItemBuffered || player.getCurrentMediaItem() != item) {
return;
}
if (isBufferedState(buffState)) {
currentMediaItemBuffered = true;
updateAllowedCommands();
}
}
private void updateAllowedCommands() {
for (MediaSession session : sessions) {
List<MediaSession.ControllerInfo> connectedControllers = session.getConnectedControllers();
for (MediaSession.ControllerInfo controller : connectedControllers) {
SessionCommandGroup baseAllowedCommands = buildAllowedCommands(session, controller);
SessionCommandGroup allowedCommands =
allowedCommandProvider.getAllowedCommands(session, controller, baseAllowedCommands);
if (allowedCommands == null) {
allowedCommands = new SessionCommandGroup.Builder().build();
}
session.setAllowedCommands(controller, allowedCommands);
}
}
}
}
}

View File

@ -0,0 +1,563 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.Manifest;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media.MediaSessionManager;
import androidx.media.MediaSessionManager.RemoteUserInfo;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.Rating;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.UriMediaItem;
import androidx.media2.session.MediaController;
import androidx.media2.session.MediaSession;
import androidx.media2.session.MediaSession.ControllerInfo;
import androidx.media2.session.SessionCommand;
import androidx.media2.session.SessionCommandGroup;
import androidx.media2.session.SessionResult;
import com.google.android.exoplayer2.util.Assertions;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
/**
* Builds {@link MediaSession.SessionCallback} with various collaborators.
*
* @see MediaSession.SessionCallback
*/
public final class SessionCallbackBuilder {
/** Default timeout value for {@link #setSeekTimeoutMs}. */
public static final int DEFAULT_SEEK_TIMEOUT_MS = 1_000;
private final Context context;
private final SessionPlayerConnector sessionPlayerConnector;
private int fastForwardMs;
private int rewindMs;
private int seekTimeoutMs;
@Nullable private RatingCallback ratingCallback;
@Nullable private CustomCommandProvider customCommandProvider;
@Nullable private MediaItemProvider mediaItemProvider;
@Nullable private AllowedCommandProvider allowedCommandProvider;
@Nullable private SkipCallback skipCallback;
@Nullable private PostConnectCallback postConnectCallback;
@Nullable private DisconnectedCallback disconnectedCallback;
/** Provides allowed commands for {@link MediaController}. */
public interface AllowedCommandProvider {
/**
* Called to query whether to allow connection from the controller.
*
* <p>If it returns {@code true} to accept connection, then {@link #getAllowedCommands} will be
* immediately followed to return initial allowed command.
*
* <p>Prefer use {@link PostConnectCallback} for any extra initialization about controller,
* where controller is connected and session can send commands to the controller.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that is requesting
* connect.
* @return {@code true} to accept connection. {@code false} otherwise.
*/
boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo);
/**
* Called to query allowed commands in following cases:
*
* <ul>
* <li>A {@link MediaController} requests to connect, and allowed commands is required to tell
* initial allowed commands.
* <li>Underlying {@link SessionPlayer} state changes, and allowed commands may be updated via
* {@link MediaSession#setAllowedCommands}.
* </ul>
*
* <p>The provided {@code baseAllowedSessionCommand} is built automatically based on the state
* of the {@link SessionPlayer}, {@link RatingCallback}, {@link MediaItemProvider}, {@link
* CustomCommandProvider}, and {@link SkipCallback} so may be a useful starting point for any
* required customizations.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller for which allowed
* commands are being queried.
* @param baseAllowedSessionCommand Base allowed session commands for customization.
* @return The allowed commands for the controller.
* @see MediaSession.SessionCallback#onConnect(MediaSession, ControllerInfo)
*/
SessionCommandGroup getAllowedCommands(
MediaSession session,
ControllerInfo controllerInfo,
SessionCommandGroup baseAllowedSessionCommand);
/**
* Called when a {@link MediaController} has called an API that controls {@link SessionPlayer}
* set to the {@link MediaSession}.
*
* @param session The media session.
* @param controllerInfo A {@link ControllerInfo} that needs allowed command update.
* @param command A {@link SessionCommand} from the controller.
* @return A session result code defined in {@link SessionResult}.
* @see MediaSession.SessionCallback#onCommandRequest
*/
int onCommandRequest(
MediaSession session, ControllerInfo controllerInfo, SessionCommand command);
}
/** Callback receiving a user rating for a specified media id. */
public interface RatingCallback {
/**
* Called when the specified controller has set a rating for the specified media id.
*
* @see MediaSession.SessionCallback#onSetRating(MediaSession, MediaSession.ControllerInfo,
* String, Rating)
* @see androidx.media2.session.MediaController#setRating(String, Rating)
* @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
* failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
* operation succeeded.
*/
int onSetRating(MediaSession session, ControllerInfo controller, String mediaId, Rating rating);
}
/**
* Callbacks for querying what custom commands are supported, and for handling a custom command
* when a controller sends it.
*/
public interface CustomCommandProvider {
/**
* Called when a controller has sent a custom command.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that sent the custom
* command.
* @param customCommand A {@link SessionCommand} from the controller.
* @param args A {@link Bundle} with the extra argument.
* @see MediaSession.SessionCallback#onCustomCommand(MediaSession, MediaSession.ControllerInfo,
* SessionCommand, Bundle)
* @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
*/
SessionResult onCustomCommand(
MediaSession session,
ControllerInfo controllerInfo,
SessionCommand customCommand,
@Nullable Bundle args);
/**
* Returns a {@link SessionCommandGroup} with custom commands to publish to the controller, or
* {@code null} if no custom commands should be published.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that is requesting custom
* commands.
* @return The custom commands to publish, or {@code null} if no custom commands should be
* published.
*/
@Nullable
SessionCommandGroup getCustomCommands(MediaSession session, ControllerInfo controllerInfo);
}
/** Provides the {@link MediaItem}. */
public interface MediaItemProvider {
/**
* Called when {@link MediaSession.SessionCallback#onCreateMediaItem(MediaSession,
* ControllerInfo, String)} is called.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
* create the item.
* @return A new {@link MediaItem} that {@link SessionPlayerConnector} can play.
* @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
* @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
* @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
* @see androidx.media2.session.MediaController#setMediaItem(String)
* @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
*/
@Nullable
MediaItem onCreateMediaItem(
MediaSession session, ControllerInfo controllerInfo, String mediaId);
}
/** Callback receiving skip backward and skip forward. */
public interface SkipCallback {
/**
* Called when the specified controller has sent skip backward.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
* skip backward.
* @see MediaSession.SessionCallback#onSkipBackward(MediaSession, MediaSession.ControllerInfo)
* @see MediaController#skipBackward()
* @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
* failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
* operation succeeded.
*/
int onSkipBackward(MediaSession session, ControllerInfo controllerInfo);
/**
* Called when the specified controller has sent skip forward.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that has requested to
* skip forward.
* @see MediaSession.SessionCallback#onSkipForward(MediaSession, MediaSession.ControllerInfo)
* @see MediaController#skipForward()
* @return One of the {@link SessionResult} {@code RESULT_*} constants describing the success or
* failure of the operation, for example, {@link SessionResult#RESULT_SUCCESS} if the
* operation succeeded.
*/
int onSkipForward(MediaSession session, ControllerInfo controllerInfo);
}
/** Callback for handling extra initialization after the connection. */
public interface PostConnectCallback {
/**
* Called after the specified controller is connected, and you need extra initialization.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the controller that just connected.
* @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
*/
void onPostConnect(MediaSession session, MediaSession.ControllerInfo controllerInfo);
}
/** Callback for handling controller disconnection. */
public interface DisconnectedCallback {
/**
* Called when the specified controller is disconnected.
*
* @param session The media session.
* @param controllerInfo The {@link ControllerInfo} for the disconnected controller.
* @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
*/
void onDisconnected(MediaSession session, MediaSession.ControllerInfo controllerInfo);
}
/**
* Default implementation of {@link AllowedCommandProvider} that behaves as follows:
*
* <ul>
* <li>Accepts connection requests from controller if any of the following conditions are met:
* <ul>
* <li>Controller is in the same package as the session.
* <li>Controller is allowed via {@link #setTrustedPackageNames(List)}.
* <li>Controller has package name {@link RemoteUserInfo#LEGACY_CONTROLLER}. See {@link
* ControllerInfo#getPackageName() package name limitation} for details.
* <li>Controller is trusted (i.e. has MEDIA_CONTENT_CONTROL permission or has enabled
* notification manager).
* </ul>
* <li>Allows all commands that the current player can handle.
* <li>Accepts all command requests for allowed commands.
* </ul>
*
* <p>Note: this implementation matches the behavior of the ExoPlayer MediaSession extension and
* {@link android.support.v4.media.session.MediaSessionCompat}.
*/
public static final class DefaultAllowedCommandProvider implements AllowedCommandProvider {
private final Context context;
private final List<String> trustedPackageNames;
public DefaultAllowedCommandProvider(Context context) {
this.context = context;
trustedPackageNames = new ArrayList<>();
}
@Override
public boolean acceptConnection(MediaSession session, ControllerInfo controllerInfo) {
return TextUtils.equals(controllerInfo.getPackageName(), context.getPackageName())
|| TextUtils.equals(controllerInfo.getPackageName(), RemoteUserInfo.LEGACY_CONTROLLER)
|| trustedPackageNames.contains(controllerInfo.getPackageName())
|| isTrusted(controllerInfo);
}
@Override
public SessionCommandGroup getAllowedCommands(
MediaSession session,
ControllerInfo controllerInfo,
SessionCommandGroup baseAllowedSessionCommands) {
return baseAllowedSessionCommands;
}
@Override
public int onCommandRequest(
MediaSession session, ControllerInfo controllerInfo, SessionCommand command) {
return SessionResult.RESULT_SUCCESS;
}
/**
* Sets the package names from which the session will accept incoming connections.
*
* <p>Apps that have {@code android.Manifest.permission.MEDIA_CONTENT_CONTROL}, packages listed
* in enabled_notification_listeners and the current package are always trusted, even if they
* are not specified here.
*
* @param packageNames Package names from which the session will accept incoming connections.
* @see MediaSession.SessionCallback#onConnect(MediaSession, MediaSession.ControllerInfo)
* @see MediaSessionManager#isTrustedForMediaControl(RemoteUserInfo)
*/
public void setTrustedPackageNames(@Nullable List<String> packageNames) {
trustedPackageNames.clear();
if (packageNames != null && !packageNames.isEmpty()) {
trustedPackageNames.addAll(packageNames);
}
}
// TODO: Replace with ControllerInfo#isTrusted() when it's unhidden [Internal: b/142835448].
private boolean isTrusted(MediaSession.ControllerInfo controllerInfo) {
// Check whether the controller has granted MEDIA_CONTENT_CONTROL.
if (context
.getPackageManager()
.checkPermission(
Manifest.permission.MEDIA_CONTENT_CONTROL, controllerInfo.getPackageName())
== PackageManager.PERMISSION_GRANTED) {
return true;
}
// Check whether the app has an enabled notification listener.
String enabledNotificationListeners =
Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners");
if (!TextUtils.isEmpty(enabledNotificationListeners)) {
String[] components = enabledNotificationListeners.split(":");
for (String componentString : components) {
@Nullable ComponentName component = ComponentName.unflattenFromString(componentString);
if (component != null) {
if (component.getPackageName().equals(controllerInfo.getPackageName())) {
return true;
}
}
}
}
return false;
}
}
/**
* Default implementation of {@link MediaItemProvider} that assumes the media id is a URI string.
*/
public static final class DefaultMediaItemProvider implements MediaItemProvider {
@Override
@Nullable
public MediaItem onCreateMediaItem(
MediaSession session, ControllerInfo controllerInfo, String mediaId) {
if (TextUtils.isEmpty(mediaId)) {
return null;
}
try {
new URI(mediaId);
} catch (URISyntaxException e) {
// Ignore if mediaId isn't a URI.
return null;
}
MediaMetadata metadata =
new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, mediaId)
.build();
return new UriMediaItem.Builder(Uri.parse(mediaId)).setMetadata(metadata).build();
}
}
/**
* Creates a new builder.
*
* <p>The builder uses the following default values:
*
* <ul>
* <li>{@link AllowedCommandProvider}: {@link DefaultAllowedCommandProvider}
* <li>Seek timeout: {@link #DEFAULT_SEEK_TIMEOUT_MS}
* <li>
* </ul>
*
* Unless stated above, {@code null} or {@code 0} would be used to disallow relevant features.
*
* @param context A context.
* @param sessionPlayerConnector A session player connector to handle incoming calls from the
* controller.
*/
public SessionCallbackBuilder(Context context, SessionPlayerConnector sessionPlayerConnector) {
this.context = Assertions.checkNotNull(context);
this.sessionPlayerConnector = Assertions.checkNotNull(sessionPlayerConnector);
this.seekTimeoutMs = DEFAULT_SEEK_TIMEOUT_MS;
}
/**
* Sets the {@link RatingCallback} to handle user ratings.
*
* @param ratingCallback A rating callback.
* @return This builder.
* @see MediaSession.SessionCallback#onSetRating(MediaSession, ControllerInfo, String, Rating)
* @see androidx.media2.session.MediaController#setRating(String, Rating)
*/
public SessionCallbackBuilder setRatingCallback(@Nullable RatingCallback ratingCallback) {
this.ratingCallback = ratingCallback;
return this;
}
/**
* Sets the {@link CustomCommandProvider} to handle incoming custom commands.
*
* @param customCommandProvider A custom command provider.
* @return This builder.
* @see MediaSession.SessionCallback#onCustomCommand(MediaSession, ControllerInfo, SessionCommand,
* Bundle)
* @see androidx.media2.session.MediaController#sendCustomCommand(SessionCommand, Bundle)
*/
public SessionCallbackBuilder setCustomCommandProvider(
@Nullable CustomCommandProvider customCommandProvider) {
this.customCommandProvider = customCommandProvider;
return this;
}
/**
* Sets the {@link MediaItemProvider} that will convert media ids to {@link MediaItem MediaItems}.
*
* @param mediaItemProvider The media item provider.
* @return This builder.
* @see MediaSession.SessionCallback#onCreateMediaItem(MediaSession, ControllerInfo, String)
* @see androidx.media2.session.MediaController#addPlaylistItem(int, String)
* @see androidx.media2.session.MediaController#replacePlaylistItem(int, String)
* @see androidx.media2.session.MediaController#setMediaItem(String)
* @see androidx.media2.session.MediaController#setPlaylist(List, MediaMetadata)
*/
public SessionCallbackBuilder setMediaItemProvider(
@Nullable MediaItemProvider mediaItemProvider) {
this.mediaItemProvider = mediaItemProvider;
return this;
}
/**
* Sets the {@link AllowedCommandProvider} to provide allowed commands for controllers.
*
* @param allowedCommandProvider A allowed command provider.
* @return This builder.
*/
public SessionCallbackBuilder setAllowedCommandProvider(
@Nullable AllowedCommandProvider allowedCommandProvider) {
this.allowedCommandProvider = allowedCommandProvider;
return this;
}
/**
* Sets the {@link SkipCallback} to handle skip backward and skip forward.
*
* @param skipCallback The skip callback.
* @return This builder.
* @see MediaSession.SessionCallback#onSkipBackward(MediaSession, ControllerInfo)
* @see MediaSession.SessionCallback#onSkipForward(MediaSession, ControllerInfo)
* @see MediaController#skipBackward()
* @see MediaController#skipForward()
*/
public SessionCallbackBuilder setSkipCallback(@Nullable SkipCallback skipCallback) {
this.skipCallback = skipCallback;
return this;
}
/**
* Sets the {@link PostConnectCallback} to handle extra initialization after the connection.
*
* @param postConnectCallback The post connect callback.
* @return This builder.
* @see MediaSession.SessionCallback#onPostConnect(MediaSession, ControllerInfo)
*/
public SessionCallbackBuilder setPostConnectCallback(
@Nullable PostConnectCallback postConnectCallback) {
this.postConnectCallback = postConnectCallback;
return this;
}
/**
* Sets the {@link DisconnectedCallback} to handle cleaning up controller.
*
* @param disconnectedCallback The disconnected callback.
* @return This builder.
* @see MediaSession.SessionCallback#onDisconnected(MediaSession, ControllerInfo)
*/
public SessionCallbackBuilder setDisconnectedCallback(
@Nullable DisconnectedCallback disconnectedCallback) {
this.disconnectedCallback = disconnectedCallback;
return this;
}
/**
* Sets the rewind increment in milliseconds.
*
* @param rewindMs The rewind increment in milliseconds. A non-positive value will cause the
* rewind to be disabled.
* @return This builder.
* @see MediaSession.SessionCallback#onRewind(MediaSession, MediaSession.ControllerInfo)
* @see #setSeekTimeoutMs(int)
*/
public SessionCallbackBuilder setRewindIncrementMs(int rewindMs) {
this.rewindMs = rewindMs;
return this;
}
/**
* Sets the fast forward increment in milliseconds.
*
* @param fastForwardMs The fast forward increment in milliseconds. A non-positive value will
* cause the fast forward to be disabled.
* @return This builder.
* @see MediaSession.SessionCallback#onFastForward(MediaSession, MediaSession.ControllerInfo)
* @see #setSeekTimeoutMs(int)
*/
public SessionCallbackBuilder setFastForwardIncrementMs(int fastForwardMs) {
this.fastForwardMs = fastForwardMs;
return this;
}
/**
* Sets the timeout in milliseconds for fast forward and rewind operations, or {@code 0} for no
* timeout. If a timeout is set, controllers will receive an error if the session's call to {@link
* SessionPlayer#seekTo} takes longer than this amount of time.
*
* @param seekTimeoutMs A timeout for {@link SessionPlayer#seekTo}. A non-positive value will wait
* forever.
* @return This builder.
*/
public SessionCallbackBuilder setSeekTimeoutMs(int seekTimeoutMs) {
this.seekTimeoutMs = seekTimeoutMs;
return this;
}
/**
* Builds {@link MediaSession.SessionCallback}.
*
* @return A new callback for a media session.
*/
public MediaSession.SessionCallback build() {
return new SessionCallback(
sessionPlayerConnector,
fastForwardMs,
rewindMs,
seekTimeoutMs,
allowedCommandProvider == null
? new DefaultAllowedCommandProvider(context)
: allowedCommandProvider,
ratingCallback,
customCommandProvider,
mediaItemProvider,
skipCallback,
postConnectCallback,
disconnectedCallback);
}
}

View File

@ -0,0 +1,764 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.util.Log;
import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy;
import androidx.annotation.IntRange;
import androidx.annotation.Nullable;
import androidx.core.util.ObjectsCompat;
import androidx.core.util.Pair;
import androidx.media.AudioAttributesCompat;
import androidx.media2.common.FileMediaItem;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer;
import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.DefaultControlDispatcher;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.PlaybackPreparer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.util.Assertions;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import org.checkerframework.checker.initialization.qual.Initialized;
import org.checkerframework.checker.nullness.compatqual.NullableType;
/**
* An implementation of {@link SessionPlayer} that wraps a given ExoPlayer {@link Player} instance.
*
* <h3>Ownership</h3>
*
* <p>{@code SessionPlayerConnector} takes ownership of the provided ExoPlayer {@link Player}
* instance between when it's constructed and when it's {@link #close() closed}. No other components
* should interact with the wrapped player (otherwise, unexpected event callbacks from the wrapped
* player may put the session player in an inconsistent state).
*
* <p>Call {@link SessionPlayer#close()} when the {@code SessionPlayerConnector} is no longer needed
* to regain ownership of the wrapped player. It is the caller's responsibility to release the
* wrapped player via {@link Player#release()}.
*
* <h3>Threading model</h3>
*
* <p>Internally this implementation posts operations to and receives callbacks on the thread
* associated with {@link Player#getApplicationLooper()}, so it is important not to block this
* thread. In particular, when awaiting the result of an asynchronous session player operation, apps
* should generally use {@link ListenableFuture#addListener(Runnable, Executor)} to be notified of
* completion, rather than calling the blocking {@link ListenableFuture#get()} method.
*/
public final class SessionPlayerConnector extends SessionPlayer {
static {
ExoPlayerLibraryInfo.registerModule("goog.exo.media2");
}
private static final String TAG = "SessionPlayerConnector";
private static final int END_OF_PLAYLIST = -1;
private final Object stateLock = new Object();
private final PlayerHandler taskHandler;
private final Executor taskHandlerExecutor;
private final PlayerWrapper player;
private final PlayerCommandQueue playerCommandQueue;
@GuardedBy("stateLock")
private final Map<MediaItem, Integer> mediaItemToBuffState = new HashMap<>();
@GuardedBy("stateLock")
/* @PlayerState */
private int state;
@GuardedBy("stateLock")
private boolean closed;
// Should be only accessed on the executor, which is currently single-threaded.
@Nullable private MediaItem currentMediaItem;
@Nullable private List<MediaItem> currentPlaylist;
/**
* Creates an instance using {@link DefaultControlDispatcher} to dispatch player commands.
*
* @param player The player to wrap.
* @param playlistManager The {@link PlaylistManager}.
* @param playbackPreparer The {@link PlaybackPreparer}.
*/
public SessionPlayerConnector(
Player player, PlaylistManager playlistManager, PlaybackPreparer playbackPreparer) {
this(player, playlistManager, playbackPreparer, new DefaultControlDispatcher());
}
/**
* Creates an instance using the provided {@link ControlDispatcher} to dispatch player commands.
*
* @param player The player to wrap.
* @param playlistManager The {@link PlaylistManager}.
* @param playbackPreparer The {@link PlaybackPreparer}.
* @param controlDispatcher The {@link ControlDispatcher}.
*/
public SessionPlayerConnector(
Player player,
PlaylistManager playlistManager,
PlaybackPreparer playbackPreparer,
ControlDispatcher controlDispatcher) {
Assertions.checkNotNull(player);
Assertions.checkNotNull(playlistManager);
Assertions.checkNotNull(playbackPreparer);
Assertions.checkNotNull(controlDispatcher);
state = PLAYER_STATE_IDLE;
taskHandler = new PlayerHandler(player.getApplicationLooper());
taskHandlerExecutor = taskHandler::postOrRun;
ExoPlayerWrapperListener playerListener = new ExoPlayerWrapperListener();
PlayerWrapper playerWrapper =
new PlayerWrapper(
playerListener, player, playlistManager, playbackPreparer, controlDispatcher);
this.player = playerWrapper;
playerCommandQueue = new PlayerCommandQueue(this.player, taskHandler);
@SuppressWarnings("assignment.type.incompatible")
@Initialized
SessionPlayerConnector initializedThis = this;
initializedThis.<Void>runPlayerCallableBlocking(
/* callable= */ () -> {
playerWrapper.reset();
return null;
});
}
@Override
public ListenableFuture<PlayerResult> play() {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ () -> player.play());
}
@Override
public ListenableFuture<PlayerResult> pause() {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ () -> player.pause());
}
@Override
public ListenableFuture<PlayerResult> prepare() {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ () -> player.prepare());
}
@Override
public ListenableFuture<PlayerResult> seekTo(long position) {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SEEK_TO,
/* command= */ () -> player.seekTo(position),
/* tag= */ position);
}
@Override
public ListenableFuture<PlayerResult> setPlaybackSpeed(
@FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false) float playbackSpeed) {
Assertions.checkArgument(playbackSpeed > 0f);
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SPEED,
/* command= */ () -> player.setPlaybackSpeed(playbackSpeed));
}
@Override
public ListenableFuture<PlayerResult> setAudioAttributes(AudioAttributesCompat attr) {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_AUDIO_ATTRIBUTES,
/* command= */ () -> player.setAudioAttributes(Assertions.checkNotNull(attr)));
}
@Override
/* @PlayerState */
public int getPlayerState() {
synchronized (stateLock) {
return state;
}
}
@Override
public long getCurrentPosition() {
long position =
runPlayerCallableBlocking(
/* callable= */ player::getCurrentPosition,
/* defaultValueWhenException= */ UNKNOWN_TIME);
return position >= 0 ? position : UNKNOWN_TIME;
}
@Override
public long getDuration() {
long position =
runPlayerCallableBlocking(
/* callable= */ player::getDuration, /* defaultValueWhenException= */ UNKNOWN_TIME);
return position >= 0 ? position : UNKNOWN_TIME;
}
@Override
public long getBufferedPosition() {
long position =
runPlayerCallableBlocking(
/* callable= */ player::getBufferedPosition,
/* defaultValueWhenException= */ UNKNOWN_TIME);
return position >= 0 ? position : UNKNOWN_TIME;
}
@Override
/* @BuffState */
public int getBufferingState() {
@Nullable
MediaItem mediaItem =
this.<@NullableType MediaItem>runPlayerCallableBlocking(
/* callable= */ player::getCurrentMediaItem, /* defaultValueWhenException= */ null);
if (mediaItem == null) {
return BUFFERING_STATE_UNKNOWN;
}
@Nullable Integer buffState;
synchronized (stateLock) {
buffState = mediaItemToBuffState.get(mediaItem);
}
return buffState == null ? BUFFERING_STATE_UNKNOWN : buffState;
}
@Override
@FloatRange(from = 0.0f, to = Float.MAX_VALUE, fromInclusive = false)
public float getPlaybackSpeed() {
return runPlayerCallableBlocking(
/* callable= */ player::getPlaybackSpeed, /* defaultValueWhenException= */ 1.0f);
}
@Override
@Nullable
public AudioAttributesCompat getAudioAttributes() {
return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getAudioAttributes);
}
@Override
public ListenableFuture<PlayerResult> setMediaItem(MediaItem item) {
Assertions.checkNotNull(item);
Assertions.checkArgument(!(item instanceof FileMediaItem));
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, () -> player.setMediaItem(item));
result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> setPlaylist(
final List<MediaItem> playlist, @Nullable MediaMetadata metadata) {
Assertions.checkNotNull(playlist);
Assertions.checkArgument(!playlist.isEmpty());
for (int i = 0; i < playlist.size(); i++) {
MediaItem item = playlist.get(i);
Assertions.checkNotNull(item);
Assertions.checkArgument(!(item instanceof FileMediaItem));
for (int j = 0; j < i; j++) {
Assertions.checkArgument(
item != playlist.get(j),
"playlist shouldn't contain duplicated item, index=" + i + " vs index=" + j);
}
}
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_PLAYLIST,
/* command= */ () -> player.setPlaylist(playlist, metadata));
result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> addPlaylistItem(int index, MediaItem item) {
Assertions.checkArgument(index >= 0);
Assertions.checkNotNull(item);
Assertions.checkArgument(!(item instanceof FileMediaItem));
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM,
/* command= */ () -> player.addPlaylistItem(index, item));
result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> removePlaylistItem(@IntRange(from = 0) int index) {
Assertions.checkArgument(index >= 0);
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM,
/* command= */ () -> player.removePlaylistItem(index));
result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> replacePlaylistItem(int index, MediaItem item) {
Assertions.checkArgument(index >= 0);
Assertions.checkNotNull(item);
Assertions.checkArgument(!(item instanceof FileMediaItem));
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM,
/* command= */ () -> player.replacePlaylistItem(index, item));
result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> skipToPreviousPlaylistItem() {
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM,
/* command= */ player::skipToPreviousPlaylistItem);
result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> skipToNextPlaylistItem() {
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM,
/* command= */ player::skipToNextPlaylistItem);
result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> skipToPlaylistItem(@IntRange(from = 0) int index) {
Assertions.checkArgument(index >= 0);
ListenableFuture<PlayerResult> result =
playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM,
/* command= */ () -> player.skipToPlaylistItem(index));
result.addListener(this::notifySkipToCompletedOnHandler, taskHandlerExecutor);
return result;
}
@Override
public ListenableFuture<PlayerResult> updatePlaylistMetadata(@Nullable MediaMetadata metadata) {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA,
/* command= */ () -> {
boolean handled = player.updatePlaylistMetadata(metadata);
if (handled) {
notifySessionPlayerCallback(
callback ->
callback.onPlaylistMetadataChanged(SessionPlayerConnector.this, metadata));
}
return handled;
});
}
@Override
public ListenableFuture<PlayerResult> setRepeatMode(int repeatMode) {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_REPEAT_MODE,
/* command= */ () -> player.setRepeatMode(repeatMode));
}
@Override
public ListenableFuture<PlayerResult> setShuffleMode(int shuffleMode) {
return playerCommandQueue.addCommand(
PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE,
/* command= */ () -> player.setShuffleMode(shuffleMode));
}
@Override
@Nullable
public List<MediaItem> getPlaylist() {
return runPlayerCallableBlockingWithNullOnException(/* callable= */ player::getPlaylist);
}
@Override
@Nullable
public MediaMetadata getPlaylistMetadata() {
return runPlayerCallableBlockingWithNullOnException(
/* callable= */ player::getPlaylistMetadata);
}
@Override
public int getRepeatMode() {
return runPlayerCallableBlocking(
/* callable= */ player::getRepeatMode, /* defaultValueWhenException= */ REPEAT_MODE_NONE);
}
@Override
public int getShuffleMode() {
return runPlayerCallableBlocking(
/* callable= */ player::getShuffleMode, /* defaultValueWhenException= */ SHUFFLE_MODE_NONE);
}
@Override
@Nullable
public MediaItem getCurrentMediaItem() {
return runPlayerCallableBlockingWithNullOnException(
/* callable= */ player::getCurrentMediaItem);
}
@Override
public int getCurrentMediaItemIndex() {
return runPlayerCallableBlocking(
/* callable= */ player::getCurrentMediaItemIndex,
/* defaultValueWhenException= */ END_OF_PLAYLIST);
}
@Override
public int getPreviousMediaItemIndex() {
return runPlayerCallableBlocking(
/* callable= */ player::getPreviousMediaItemIndex,
/* defaultValueWhenException= */ END_OF_PLAYLIST);
}
@Override
public int getNextMediaItemIndex() {
return runPlayerCallableBlocking(
/* callable= */ player::getNextMediaItemIndex,
/* defaultValueWhenException= */ END_OF_PLAYLIST);
}
// TODO(b/147706139): Call super.close() after updating media2-common to 1.1.0
@SuppressWarnings("MissingSuperCall")
@Override
public void close() {
synchronized (stateLock) {
if (closed) {
return;
}
closed = true;
}
reset();
this.<Void>runPlayerCallableBlockingInternal(
/* callable= */ () -> {
player.close();
return null;
});
}
// SessionPlayerConnector-specific functions.
/**
* Returns whether the current media item is seekable.
*
* @return {@code true} if supported. {@code false} otherwise.
*/
/* package */ boolean isCurrentMediaItemSeekable() {
return runPlayerCallableBlocking(
/* callable= */ player::isCurrentMediaItemSeekable, /* defaultValueWhenException= */ false);
}
/**
* Returns whether {@link #skipToPlaylistItem(int)} is supported.
*
* @return {@code true} if supported. {@code false} otherwise.
*/
/* package */ boolean canSkipToPlaylistItem() {
return runPlayerCallableBlocking(
/* callable= */ player::canSkipToPlaylistItem, /* defaultValueWhenException= */ false);
}
/**
* Returns whether {@link #skipToPreviousPlaylistItem()} is supported.
*
* @return {@code true} if supported. {@code false} otherwise.
*/
/* package */ boolean canSkipToPreviousPlaylistItem() {
return runPlayerCallableBlocking(
/* callable= */ player::canSkipToPreviousPlaylistItem,
/* defaultValueWhenException= */ false);
}
/**
* Returns whether {@link #skipToNextPlaylistItem()} is supported.
*
* @return {@code true} if supported. {@code false} otherwise.
*/
/* package */ boolean canSkipToNextPlaylistItem() {
return runPlayerCallableBlocking(
/* callable= */ player::canSkipToNextPlaylistItem, /* defaultValueWhenException= */ false);
}
/**
* Resets {@link SessionPlayerConnector} to its uninitialized state if not closed. After calling
* this method, you will have to initialize it again by setting the media item and calling {@link
* #prepare()}.
*
* <p>Note that if the player is closed, there is no way to reuse the instance.
*/
private void reset() {
// Cancel the pending commands.
playerCommandQueue.reset();
synchronized (stateLock) {
state = PLAYER_STATE_IDLE;
mediaItemToBuffState.clear();
}
this.<Void>runPlayerCallableBlockingInternal(
/* callable= */ () -> {
player.reset();
return null;
});
}
private void setState(/* @PlayerState */ int state) {
boolean needToNotify = false;
synchronized (stateLock) {
if (this.state != state) {
this.state = state;
needToNotify = true;
}
}
if (needToNotify) {
notifySessionPlayerCallback(
callback -> callback.onPlayerStateChanged(SessionPlayerConnector.this, state));
}
}
private void setBufferingState(MediaItem item, /* @BuffState */ int state) {
@Nullable Integer previousState;
synchronized (stateLock) {
previousState = mediaItemToBuffState.put(item, state);
}
if (previousState == null || previousState != state) {
notifySessionPlayerCallback(
callback -> callback.onBufferingStateChanged(SessionPlayerConnector.this, item, state));
}
}
private void notifySessionPlayerCallback(SessionPlayerCallbackNotifier notifier) {
synchronized (stateLock) {
if (closed) {
return;
}
}
List<Pair<SessionPlayer.PlayerCallback, Executor>> callbacks = getCallbacks();
for (Pair<SessionPlayer.PlayerCallback, Executor> pair : callbacks) {
SessionPlayer.PlayerCallback callback = Assertions.checkNotNull(pair.first);
Executor executor = Assertions.checkNotNull(pair.second);
executor.execute(() -> notifier.callCallback(callback));
}
}
private void handlePlaylistChangedOnHandler() {
List<MediaItem> currentPlaylist = player.getPlaylist();
boolean notifyCurrentPlaylist = !ObjectsCompat.equals(this.currentPlaylist, currentPlaylist);
this.currentPlaylist = currentPlaylist;
MediaMetadata playlistMetadata = player.getPlaylistMetadata();
MediaItem currentMediaItem = player.getCurrentMediaItem();
boolean notifyCurrentMediaItem = !ObjectsCompat.equals(this.currentMediaItem, currentMediaItem);
this.currentMediaItem = currentMediaItem;
if (!notifyCurrentMediaItem && !notifyCurrentPlaylist) {
return;
}
notifySessionPlayerCallback(
callback -> {
if (notifyCurrentPlaylist) {
callback.onPlaylistChanged(
SessionPlayerConnector.this, currentPlaylist, playlistMetadata);
}
if (notifyCurrentMediaItem) {
Assertions.checkNotNull(
currentMediaItem, "PlaylistManager#currentMediaItem() cannot be changed to null");
callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem);
}
});
}
private void notifySkipToCompletedOnHandler() {
MediaItem currentMediaItem = Assertions.checkNotNull(player.getCurrentMediaItem());
if (ObjectsCompat.equals(this.currentMediaItem, currentMediaItem)) {
return;
}
this.currentMediaItem = currentMediaItem;
notifySessionPlayerCallback(
callback ->
callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, currentMediaItem));
}
private <T> T runPlayerCallableBlocking(Callable<T> callable) {
synchronized (stateLock) {
Assertions.checkState(!closed);
}
return runPlayerCallableBlockingInternal(callable);
}
private <T> T runPlayerCallableBlockingInternal(Callable<T> callable) {
SettableFuture<T> future = SettableFuture.create();
boolean success =
taskHandler.postOrRun(
() -> {
try {
future.set(callable.call());
} catch (Throwable e) {
future.setException(e);
}
});
Assertions.checkState(success);
boolean wasInterrupted = false;
try {
while (true) {
try {
return future.get();
} catch (InterruptedException e) {
// We always wait for player calls to return.
wasInterrupted = true;
} catch (ExecutionException e) {
@Nullable Throwable cause = e.getCause();
Log.e(TAG, "Internal player error", new RuntimeException(cause));
throw new IllegalStateException(cause);
}
}
} finally {
if (wasInterrupted) {
Thread.currentThread().interrupt();
}
}
}
@Nullable
private <T> T runPlayerCallableBlockingWithNullOnException(Callable<@NullableType T> callable) {
try {
return runPlayerCallableBlocking(callable);
} catch (Exception e) {
return null;
}
}
private <T> T runPlayerCallableBlocking(Callable<T> callable, T defaultValueWhenException) {
try {
return runPlayerCallableBlocking(callable);
} catch (Exception e) {
return defaultValueWhenException;
}
}
private interface SessionPlayerCallbackNotifier {
void callCallback(SessionPlayer.PlayerCallback callback);
}
private final class ExoPlayerWrapperListener implements PlayerWrapper.Listener {
@Override
public void onPlayerStateChanged(int playerState) {
setState(playerState);
if (playerState == PLAYER_STATE_PLAYING) {
playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY);
} else if (playerState == PLAYER_STATE_PAUSED) {
playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE);
}
}
@Override
public void onPrepared(MediaItem mediaItem, int bufferingPercentage) {
Assertions.checkNotNull(mediaItem);
if (bufferingPercentage >= 100) {
setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
} else {
setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE);
}
playerCommandQueue.notifyCommandCompleted(PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE);
}
@Override
public void onSeekCompleted() {
notifySessionPlayerCallback(
callback -> callback.onSeekCompleted(SessionPlayerConnector.this, getCurrentPosition()));
}
@Override
public void onBufferingStarted(MediaItem mediaItem) {
setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_STARVED);
}
@Override
public void onBufferingUpdate(MediaItem mediaItem, int bufferingPercentage) {
if (bufferingPercentage >= 100) {
setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
}
}
@Override
public void onBufferingEnded(MediaItem mediaItem, int bufferingPercentage) {
if (bufferingPercentage >= 100) {
setBufferingState(mediaItem, BUFFERING_STATE_COMPLETE);
} else {
setBufferingState(mediaItem, BUFFERING_STATE_BUFFERING_AND_PLAYABLE);
}
}
@Override
public void onCurrentMediaItemChanged(MediaItem mediaItem) {
if (ObjectsCompat.equals(currentMediaItem, mediaItem)) {
return;
}
currentMediaItem = mediaItem;
notifySessionPlayerCallback(
callback -> callback.onCurrentMediaItemChanged(SessionPlayerConnector.this, mediaItem));
}
@Override
public void onPlaybackEnded() {
notifySessionPlayerCallback(
callback -> callback.onPlaybackCompleted(SessionPlayerConnector.this));
}
@Override
public void onError(@Nullable MediaItem mediaItem) {
playerCommandQueue.notifyCommandError();
if (mediaItem != null) {
setBufferingState(mediaItem, BUFFERING_STATE_UNKNOWN);
}
}
@Override
public void onPlaylistChanged() {
handlePlaylistChangedOnHandler();
}
@Override
public void onShuffleModeChanged(int shuffleMode) {
notifySessionPlayerCallback(
callback -> callback.onShuffleModeChanged(SessionPlayerConnector.this, shuffleMode));
}
@Override
public void onRepeatModeChanged(int repeatMode) {
notifySessionPlayerCallback(
callback -> callback.onRepeatModeChanged(SessionPlayerConnector.this, repeatMode));
}
@Override
public void onPlaybackSpeedChanged(float playbackSpeed) {
notifySessionPlayerCallback(
callback -> callback.onPlaybackSpeedChanged(SessionPlayerConnector.this, playbackSpeed));
}
@Override
public void onAudioAttributesChanged(AudioAttributesCompat audioAttributes) {
notifySessionPlayerCallback(
callback ->
callback.onAudioAttributesChanged(SessionPlayerConnector.this, audioAttributes));
}
}
}

View File

@ -0,0 +1,90 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import com.google.android.exoplayer2.util.Assertions;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
/**
* A replacement of com.google.common.util.concurrent.SettableFuture with CallbackToFutureAdapter to
* avoid the dependency on Guava.
*/
@SuppressWarnings("ShouldNotSubclass")
/* package */ class SettableFuture<V> implements ListenableFuture<V> {
static <V> SettableFuture<V> create() {
return new SettableFuture<>();
}
private final ListenableFuture<V> future;
private final CallbackToFutureAdapter.Completer<V> completer;
SettableFuture() {
AtomicReference<CallbackToFutureAdapter.Completer<V>> completerRef = new AtomicReference<>();
future =
CallbackToFutureAdapter.getFuture(
completer -> {
completerRef.set(completer);
return null;
});
completer = Assertions.checkNotNull(completerRef.get());
}
@Override
public void addListener(Runnable listener, Executor executor) {
future.addListener(listener, executor);
}
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
return future.cancel(mayInterruptIfRunning);
}
@Override
public boolean isCancelled() {
return future.isCancelled();
}
@Override
public boolean isDone() {
return future.isDone();
}
@Override
public V get() throws ExecutionException, InterruptedException {
return future.get();
}
@Override
public V get(long timeout, TimeUnit unit)
throws ExecutionException, InterruptedException, TimeoutException {
return future.get(timeout, unit);
}
void set(V value) {
completer.set(value);
}
void setException(Throwable throwable) {
completer.setException(throwable);
}
}

View File

@ -0,0 +1,332 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.content.Context;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.media2.common.CallbackMediaItem;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.ExoPlayerLibraryInfo;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.ClippingMediaSource;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* A default {@link PlaylistManager} implementation based on {@link ConcatenatingMediaSource} that
* maps a {@link MediaItem} in the playlist to a {@link MediaSource}.
*
* <p>When it's used, {@link Player}'s Timeline shouldn't be changed directly, and should be only
* changed via {@link TimelinePlaylistManager}. If it's not, internal playlist would be out of sync
* with the actual {@link Timeline}. If you need to change Timeline directly, build your own {@link
* PlaylistManager} instead.
*/
public class TimelinePlaylistManager implements PlaylistManager {
private static final String TAG = "TimelinePlaylistManager";
private final MediaSourceFactory sourceFactory;
private final ConcatenatingMediaSource concatenatingMediaSource;
private final List<MediaItem> playlist;
@Nullable private MediaMetadata playlistMetadata;
private boolean loggedUnexpectedTimelineChanges;
/** Factory to create {@link MediaSource}s. */
public interface MediaSourceFactory {
/**
* Creates a {@link MediaSource} for the given {@link MediaItem}.
*
* @param mediaItem The {@link MediaItem} to create a media source for.
* @return A {@link MediaSource} or {@code null} if no source can be created for the given
* description.
*/
@Nullable
MediaSource createMediaSource(MediaItem mediaItem);
}
/**
* Default implementation of the {@link MediaSourceFactory}.
*
* <p>This doesn't support the {@link androidx.media2.common.FileMediaItem}.
*/
public static final class DefaultMediaSourceFactory implements MediaSourceFactory {
private final Context context;
private final DataSource.Factory dataSourceFactory;
/**
* Default constructor with {@link DefaultDataSourceFactory}.
*
* @param context The context.
*/
public DefaultMediaSourceFactory(Context context) {
this(
context,
new DefaultDataSourceFactory(
context, Util.getUserAgent(context, ExoPlayerLibraryInfo.VERSION_SLASHY)));
}
/**
* Default constructor with {@link DataSource.Factory}.
*
* @param context The context.
* @param dataSourceFactory The dataSourceFactory to create {@link MediaSource} from {@link
* MediaItem}.
*/
public DefaultMediaSourceFactory(Context context, DataSource.Factory dataSourceFactory) {
this.context = Assertions.checkNotNull(context);
this.dataSourceFactory = Assertions.checkNotNull(dataSourceFactory);
}
@Override
public MediaSource createMediaSource(MediaItem mediaItem) {
// Create a source for the item.
MediaSource mediaSource =
Utils.createUnclippedMediaSource(context, dataSourceFactory, mediaItem);
// Apply clipping if needed.
long startPosition = mediaItem.getStartPosition();
long endPosition = mediaItem.getEndPosition();
if (startPosition != 0L || endPosition != MediaItem.POSITION_UNKNOWN) {
if (endPosition == MediaItem.POSITION_UNKNOWN) {
endPosition = C.TIME_END_OF_SOURCE;
}
// Disable the initial discontinuity to give seamless transitions to clips.
mediaSource =
new ClippingMediaSource(
mediaSource,
C.msToUs(startPosition),
C.msToUs(endPosition),
/* enableInitialDiscontinuity= */ false,
/* allowDynamicClippingUpdates= */ false,
/* relativeToDefaultPosition= */ true);
}
return mediaSource;
}
}
/**
* Creates a new {@link TimelinePlaylistManager} with the {@link DefaultMediaSourceFactory}.
*
* @param context The context.
* @param concatenatingMediaSource The {@link ConcatenatingMediaSource} to manipulate.
*/
public TimelinePlaylistManager(
Context context, ConcatenatingMediaSource concatenatingMediaSource) {
this(concatenatingMediaSource, new DefaultMediaSourceFactory(context));
}
/**
* Creates a new {@link TimelinePlaylistManager} with a given mediaSourceFactory.
*
* @param concatenatingMediaSource The {@link ConcatenatingMediaSource} to manipulate.
* @param sourceFactory The {@link MediaSourceFactory} to build media sources.
*/
public TimelinePlaylistManager(
ConcatenatingMediaSource concatenatingMediaSource, MediaSourceFactory sourceFactory) {
this.concatenatingMediaSource = concatenatingMediaSource;
this.sourceFactory = sourceFactory;
this.playlist = new ArrayList<>();
}
@Override
public boolean setPlaylist(
Player player, List<MediaItem> playlist, @Nullable MediaMetadata metadata) {
// Check for duplication.
for (int i = 0; i < playlist.size(); i++) {
MediaItem mediaItem = playlist.get(i);
Assertions.checkArgument(playlist.indexOf(mediaItem) == i);
}
for (MediaItem mediaItem : this.playlist) {
if (!playlist.contains(mediaItem)) {
releaseMediaItem(mediaItem);
}
}
this.playlist.clear();
this.playlist.addAll(playlist);
this.playlistMetadata = metadata;
concatenatingMediaSource.clear();
List<MediaSource> mediaSources = new ArrayList<>();
for (int i = 0; i < playlist.size(); i++) {
MediaItem mediaItem = playlist.get(i);
MediaSource mediaSource = createMediaSource(mediaItem);
mediaSources.add(mediaSource);
}
concatenatingMediaSource.addMediaSources(mediaSources);
return true;
}
@Override
public boolean addPlaylistItem(Player player, int index, MediaItem mediaItem) {
Assertions.checkArgument(!playlist.contains(mediaItem));
index = Util.constrainValue(index, 0, playlist.size());
playlist.add(index, mediaItem);
MediaSource mediaSource = createMediaSource(mediaItem);
concatenatingMediaSource.addMediaSource(index, mediaSource);
return true;
}
@Override
public boolean removePlaylistItem(Player player, int index) {
MediaItem mediaItemToRemove = playlist.remove(index);
releaseMediaItem(mediaItemToRemove);
concatenatingMediaSource.removeMediaSource(index);
return true;
}
@Override
public boolean replacePlaylistItem(Player player, int index, MediaItem mediaItem) {
Assertions.checkArgument(!playlist.contains(mediaItem));
index = Util.constrainValue(index, 0, playlist.size());
MediaItem mediaItemToRemove = playlist.get(index);
playlist.set(index, mediaItem);
releaseMediaItem(mediaItemToRemove);
MediaSource mediaSourceToAdd = createMediaSource(mediaItem);
concatenatingMediaSource.removeMediaSource(index);
concatenatingMediaSource.addMediaSource(index, mediaSourceToAdd);
return true;
}
@Override
public boolean setMediaItem(Player player, MediaItem mediaItem) {
// TODO(jaewan): Distinguish setMediaItem(item) and setPlaylist({item})
List<MediaItem> playlist = new ArrayList<>();
playlist.add(mediaItem);
return setPlaylist(player, playlist, /* metadata */ null);
}
@Override
public boolean updatePlaylistMetadata(Player player, @Nullable MediaMetadata metadata) {
this.playlistMetadata = metadata;
return true;
}
@Override
public boolean skipToNextPlaylistItem(Player player, ControlDispatcher controlDispatcher) {
Timeline timeline = player.getCurrentTimeline();
Assertions.checkState(!timeline.isEmpty());
int nextWindowIndex = player.getNextWindowIndex();
if (nextWindowIndex != C.INDEX_UNSET) {
return controlDispatcher.dispatchSeekTo(player, nextWindowIndex, C.TIME_UNSET);
}
return false;
}
@Override
public boolean skipToPreviousPlaylistItem(Player player, ControlDispatcher controlDispatcher) {
Timeline timeline = player.getCurrentTimeline();
Assertions.checkState(!timeline.isEmpty());
int previousWindowIndex = player.getPreviousWindowIndex();
if (previousWindowIndex != C.INDEX_UNSET) {
return controlDispatcher.dispatchSeekTo(player, previousWindowIndex, C.TIME_UNSET);
}
return false;
}
@Override
public boolean skipToPlaylistItem(Player player, ControlDispatcher controlDispatcher, int index) {
Timeline timeline = player.getCurrentTimeline();
Assertions.checkState(!timeline.isEmpty());
// Use checkState() instead of checkIndex() for throwing IllegalStateException.
// checkIndex() throws IndexOutOfBoundsException which maps the RESULT_ERROR_BAD_VALUE
// but RESULT_ERROR_INVALID_STATE with IllegalStateException is expected here.
Assertions.checkState(0 <= index && index < timeline.getWindowCount());
int windowIndex = player.getCurrentWindowIndex();
if (windowIndex != index) {
return controlDispatcher.dispatchSeekTo(player, index, C.TIME_UNSET);
}
return false;
}
@Override
public int getCurrentMediaItemIndex(Player player) {
return playlist.isEmpty() ? C.INDEX_UNSET : player.getCurrentWindowIndex();
}
@Override
@Nullable
public MediaItem getCurrentMediaItem(Player player) {
int index = getCurrentMediaItemIndex(player);
return (index != C.INDEX_UNSET) ? playlist.get(index) : null;
}
@Override
public List<MediaItem> getPlaylist(Player player) {
return new ArrayList<>(playlist);
}
@Override
@Nullable
public MediaMetadata getPlaylistMetadata(Player player) {
return playlistMetadata;
}
@Override
public void onTimelineChanged(Player player) {
checkTimelineWindowCountEqualsToPlaylistSize(player);
}
private void releaseMediaItem(MediaItem mediaItem) {
try {
if (mediaItem instanceof CallbackMediaItem) {
((CallbackMediaItem) mediaItem).getDataSourceCallback().close();
}
} catch (IOException e) {
Log.w(TAG, "Error releasing media item " + mediaItem, e);
}
}
private MediaSource createMediaSource(MediaItem mediaItem) {
return Assertions.checkNotNull(
sourceFactory.createMediaSource(mediaItem),
"createMediaSource() failed, mediaItem=" + mediaItem);
}
// Check whether Timeline's window count matches with the playlist size, and leave log for
// mismatch. It's to check whether the Timeline and playlist are out of sync or not at the best
// effort.
private void checkTimelineWindowCountEqualsToPlaylistSize(Player player) {
if (player.getPlaybackState() == Player.STATE_IDLE) {
// Cannot do check in STATE_IDLE, because Timeline isn't available.
return;
}
Timeline timeline = player.getCurrentTimeline();
if ((playlist == null && timeline.getWindowCount() == 1)
|| (playlist != null && playlist.size() == timeline.getWindowCount())) {
return;
}
if (!loggedUnexpectedTimelineChanges) {
Log.w(TAG, "Timeline is unexpectedly changed. Playlist can be out of sync.");
loggedUnexpectedTimelineChanges = true;
}
}
}

View File

@ -0,0 +1,200 @@
/*
* Copyright 2019 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 com.google.android.exoplayer2.ext.media2;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.media.AudioAttributesCompat;
import androidx.media2.common.CallbackMediaItem;
import androidx.media2.common.MediaItem;
import androidx.media2.common.SessionPlayer;
import androidx.media2.common.UriMediaItem;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
import com.google.android.exoplayer2.extractor.ExtractorsFactory;
import com.google.android.exoplayer2.extractor.ts.AdtsExtractor;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.RawResourceDataSource;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
/**
* Utility methods for the media2 extension (primarily for translating between the media2 and
* ExoPlayer {@link Player} APIs).
*/
/* package */ final class Utils {
private static final ExtractorsFactory sExtractorsFactory =
new DefaultExtractorsFactory()
.setAdtsExtractorFlags(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING);
/**
* Returns an ExoPlayer media source for the given media item. The given {@link MediaItem} is set
* as the tag of the source.
*/
public static MediaSource createUnclippedMediaSource(
Context context, DataSource.Factory dataSourceFactory, MediaItem mediaItem) {
if (mediaItem instanceof UriMediaItem) {
Uri uri = ((UriMediaItem) mediaItem).getUri();
if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme())) {
String path = Assertions.checkNotNull(uri.getPath());
int resourceIdentifier;
if (uri.getPathSegments().size() == 1 && uri.getPathSegments().get(0).matches("\\d+")) {
resourceIdentifier = Integer.parseInt(uri.getPathSegments().get(0));
} else {
if (path.startsWith("/")) {
path = path.substring(1);
}
@Nullable String host = uri.getHost();
String resourceName = (TextUtils.isEmpty(host) ? "" : (host + ":")) + path;
resourceIdentifier =
context.getResources().getIdentifier(resourceName, "raw", context.getPackageName());
}
Assertions.checkState(resourceIdentifier != 0);
uri = RawResourceDataSource.buildRawResourceUri(resourceIdentifier);
}
return createMediaSource(uri, dataSourceFactory, /* tag= */ mediaItem);
} else if (mediaItem instanceof CallbackMediaItem) {
CallbackMediaItem callbackMediaItem = (CallbackMediaItem) mediaItem;
dataSourceFactory =
DataSourceCallbackDataSource.getFactory(callbackMediaItem.getDataSourceCallback());
return new ProgressiveMediaSource.Factory(dataSourceFactory, sExtractorsFactory)
.setTag(mediaItem)
.createMediaSource(Uri.EMPTY);
} else {
throw new IllegalStateException();
}
}
/** Returns ExoPlayer audio attributes for the given audio attributes. */
public static AudioAttributes getAudioAttributes(AudioAttributesCompat audioAttributesCompat) {
return new AudioAttributes.Builder()
.setContentType(audioAttributesCompat.getContentType())
.setFlags(audioAttributesCompat.getFlags())
.setUsage(audioAttributesCompat.getUsage())
.build();
}
/** Returns audio attributes for the given ExoPlayer audio attributes. */
public static AudioAttributesCompat getAudioAttributesCompat(AudioAttributes audioAttributes) {
return new AudioAttributesCompat.Builder()
.setContentType(audioAttributes.contentType)
.setFlags(audioAttributes.flags)
.setUsage(audioAttributes.usage)
.build();
}
/** Returns the SimpleExoPlayer's shuffle mode for the given shuffle mode. */
public static boolean getExoPlayerShuffleMode(int shuffleMode) {
switch (shuffleMode) {
case SessionPlayer.SHUFFLE_MODE_ALL:
case SessionPlayer.SHUFFLE_MODE_GROUP:
return true;
case SessionPlayer.SHUFFLE_MODE_NONE:
return false;
default:
throw new IllegalArgumentException();
}
}
/** Returns the shuffle mode for the given ExoPlayer's shuffle mode */
public static int getShuffleMode(boolean exoPlayerShuffleMode) {
return exoPlayerShuffleMode ? SessionPlayer.SHUFFLE_MODE_ALL : SessionPlayer.SHUFFLE_MODE_NONE;
}
/** Returns the ExoPlayer's repeat mode for the given repeat mode. */
@Player.RepeatMode
public static int getExoPlayerRepeatMode(int repeatMode) {
switch (repeatMode) {
case SessionPlayer.REPEAT_MODE_ALL:
case SessionPlayer.REPEAT_MODE_GROUP:
return Player.REPEAT_MODE_ALL;
case SessionPlayer.REPEAT_MODE_ONE:
return Player.REPEAT_MODE_ONE;
case SessionPlayer.REPEAT_MODE_NONE:
return Player.REPEAT_MODE_OFF;
default:
throw new IllegalArgumentException();
}
}
/** Returns the repeat mode for the given SimpleExoPlayer's repeat mode. */
public static int getRepeatMode(@Player.RepeatMode int exoPlayerRepeatMode) {
switch (exoPlayerRepeatMode) {
case Player.REPEAT_MODE_ALL:
return SessionPlayer.REPEAT_MODE_ALL;
case Player.REPEAT_MODE_ONE:
return SessionPlayer.REPEAT_MODE_ONE;
case Player.REPEAT_MODE_OFF:
return SessionPlayer.REPEAT_MODE_NONE;
default:
throw new IllegalArgumentException();
}
}
private static MediaSource createMediaSource(
Uri uri, DataSource.Factory dataSourceFactory, Object tag) {
// TODO: Deduplicate with DefaultMediaSource once MediaItem support in ExoPlayer has been
// released. See [Internal: b/150857202].
@Nullable Class<? extends MediaSourceFactory> factoryClazz = null;
try {
// LINT.IfChange
switch (Util.inferContentType(uri)) {
case C.TYPE_DASH:
factoryClazz =
Class.forName("com.google.android.exoplayer2.source.dash.DashMediaSource$Factory")
.asSubclass(MediaSourceFactory.class);
break;
case C.TYPE_HLS:
factoryClazz =
Class.forName("com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory")
.asSubclass(MediaSourceFactory.class);
break;
case C.TYPE_SS:
factoryClazz =
Class.forName(
"com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory")
.asSubclass(MediaSourceFactory.class);
break;
case C.TYPE_OTHER:
default:
break;
}
if (factoryClazz != null) {
MediaSourceFactory mediaSourceFactory =
factoryClazz.getConstructor(DataSource.Factory.class).newInstance(dataSourceFactory);
factoryClazz.getMethod("setTag", Object.class).invoke(mediaSourceFactory, tag);
return mediaSourceFactory.createMediaSource(uri);
}
// LINT.ThenChange(../../../../../../../../../proguard-rules.txt)
} catch (Exception e) {
// Expected if the app was built without the corresponding module.
}
return new ProgressiveMediaSource.Factory(dataSourceFactory).setTag(tag).createMediaSource(uri);
}
private Utils() {
// Prevent instantiation.
}
}

View File

@ -0,0 +1,19 @@
/*
* Copyright 2019 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.
*/
@NonNullApi
package com.google.android.exoplayer2.ext.media2;
import com.google.android.exoplayer2.util.NonNullApi;

View File

@ -0,0 +1 @@
../../proguard-rules.txt