diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 95833d3e04..d8ad87c56c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -231,6 +231,9 @@ ([#7357](https://github.com/google/ExoPlayer/issues/7357)). * Metadata: Add minimal DVB Application Information Table (AIT) support ([#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 manipulation API. * Demo app: Retain previous position in list of samples. diff --git a/core_settings.gradle b/core_settings.gradle index 0b73665c90..b508243371 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -39,6 +39,7 @@ include modulePrefix + 'extension-ima' include modulePrefix + 'extension-cast' include modulePrefix + 'extension-cronet' include modulePrefix + 'extension-mediasession' +include modulePrefix + 'extension-media2' include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-opus' 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-cronet').projectDir = new File(rootDir, 'extensions/cronet') 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-opus').projectDir = new File(rootDir, 'extensions/opus') project(modulePrefix + 'extension-vp9').projectDir = new File(rootDir, 'extensions/vp9') diff --git a/extensions/media2/README.md b/extensions/media2/README.md new file mode 100644 index 0000000000..32ea864940 --- /dev/null +++ b/extensions/media2/README.md @@ -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 diff --git a/extensions/media2/build.gradle b/extensions/media2/build.gradle new file mode 100644 index 0000000000..5c52cc2f33 --- /dev/null +++ b/extensions/media2/build.gradle @@ -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' diff --git a/extensions/media2/proguard-rules.txt b/extensions/media2/proguard-rules.txt new file mode 100644 index 0000000000..229c0798ea --- /dev/null +++ b/extensions/media2/proguard-rules.txt @@ -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 (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 (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 (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 diff --git a/extensions/media2/src/androidTest/AndroidManifest.xml b/extensions/media2/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..b699de67b1 --- /dev/null +++ b/extensions/media2/src/androidTest/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java new file mode 100644 index 0000000000..7ef176071b --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtilTest.java @@ -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 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(); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java new file mode 100644 index 0000000000..78fea77856 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/MediaStubActivity.java @@ -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(); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java new file mode 100644 index 0000000000..acf2eb1fb6 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/PlayerTestRule.java @@ -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; + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java new file mode 100644 index 0000000000..166e82fe27 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilderTest.java @@ -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 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 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 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 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 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 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 allowedCommandCodes = + Arrays.asList(SessionCommand.COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM); + assertAllowedCommands(allowedCommandCodes, allowedCommands); + + List 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 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 future) throws Exception { + assertSessionResultSuccess(future, CONTROLLER_COMMAND_WAIT_TIME_MS); + } + + private static void assertSessionResultSuccess(Future future, long timeoutMs) + throws Exception { + SessionResult result = future.get(timeoutMs, TimeUnit.MILLISECONDS); + assertThat(result.getResultCode()).isEqualTo(SessionResult.RESULT_SUCCESS); + } + + private static void assertSessionResultFailure(Future 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 expectedAllowedCommandsCode, SessionCommandGroup allowedCommands) { + for (int commandCode : expectedAllowedCommandsCode) { + assertWithMessage("Command should be allowed, code=" + commandCode) + .that(allowedCommands.hasCommand(commandCode)) + .isTrue(); + } + } + + private static void assertDisallowedCommands( + List 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); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java new file mode 100644 index 0000000000..51f2695bf7 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnectorTest.java @@ -0,0 +1,1326 @@ +/* + * 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.PLAYER_STATE_PAUSED; +import static androidx.media2.common.SessionPlayer.PLAYER_STATE_PLAYING; +import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_INFO_SKIPPED; +import static androidx.media2.common.SessionPlayer.PlayerResult.RESULT_SUCCESS; +import static com.google.android.exoplayer2.ext.media2.TestUtils.assertPlayerResult; +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.content.res.Resources; +import android.media.AudioManager; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media.AudioAttributesCompat; +import androidx.media2.common.CallbackMediaItem; +import androidx.media2.common.DataSourceCallback; +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 androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.filters.MediumTest; +import androidx.test.filters.SdkSuppress; +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.media2.test.R; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.common.util.concurrent.ListenableFuture; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests {@link SessionPlayerConnector}. */ +@SuppressWarnings("FutureReturnValueIgnored") +@RunWith(AndroidJUnit4.class) +public class SessionPlayerConnectorTest { + @Rule + public final ActivityTestRule activityRule = + new ActivityTestRule<>(MediaStubActivity.class); + + @Rule public final PlayerTestRule playerTestRule = new PlayerTestRule(); + + private static final long PLAYLIST_CHANGE_WAIT_TIME_MS = 1_000; + private static final long PLAYER_STATE_CHANGE_WAIT_TIME_MS = 5_000; + private static final long PLAYBACK_COMPLETED_WAIT_TIME_MS = 20_000; + private static final float FLOAT_TOLERANCE = .0001f; + + private Context context; + private Resources resources; + private Executor executor; + private SessionPlayerConnector sessionPlayerConnector; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + resources = context.getResources(); + 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 + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_onceWithAudioResource_changesPlayerStateToPlaying() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + + CountDownLatch onPlayingLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayingLatch.countDown(); + } + } + }); + + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @MediumTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_onceWithAudioResourceOnMainThread_notifiesOnPlayerStateChanged() + throws Exception { + CountDownLatch onPlayerStatePlayingLatch = new CountDownLatch(1); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + try { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + } catch (Exception e) { + assertWithMessage(e.getMessage()).fail(); + } + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder() + .setLegacyStreamType(AudioManager.STREAM_MUSIC) + .build(); + sessionPlayerConnector.setAudioAttributes(attributes); + + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged( + @NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayerStatePlayingLatch.countDown(); + } + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + }); + assertThat( + onPlayerStatePlayingLatch.await( + PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withCustomControlDispatcher_isSkipped() throws Exception { + ControlDispatcher controlDispatcher = + new DefaultControlDispatcher() { + @Override + public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { + return false; + } + }; + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + ConcatenatingMediaSource concatenatingMediaSource = new ConcatenatingMediaSource(); + TimelinePlaylistManager timelinePlaylistManager = + new TimelinePlaylistManager(context, concatenatingMediaSource); + ConcatenatingMediaSourcePlaybackPreparer playbackPreparer = + new ConcatenatingMediaSourcePlaybackPreparer(simpleExoPlayer, concatenatingMediaSource); + + try (SessionPlayerConnector player = + new SessionPlayerConnector( + simpleExoPlayer, timelinePlaylistManager, playbackPreparer, controlDispatcher)) { + assertPlayerResult(player.play(), RESULT_INFO_SKIPPED); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withAudioResource_notifiesOnPlaybackCompleted() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3, sessionPlayerConnector); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + + // waiting to complete + assertThat( + onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withVideoResource_notifiesOnPlaybackCompleted() throws Exception { + TestUtils.loadResource(context, R.raw.testvideo, sessionPlayerConnector); + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + + // waiting to complete + assertThat( + onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getDuration_whenIdleState_returnsUnknownTime() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getDuration()).isEqualTo(SessionPlayer.UNKNOWN_TIME); + } + + @Test + @MediumTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getDuration_afterPrepared_returnsDuration() throws Exception { + int expectedDuration = 5130; + int tolerance = 50; + + TestUtils.loadResource(context, R.raw.testvideo, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + assertThat((float) sessionPlayerConnector.getDuration()) + .isWithin(tolerance) + .of(expectedDuration); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getCurrentPosition_whenIdleState_returnsUnknownTime() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(SessionPlayer.UNKNOWN_TIME); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getBufferedPosition_whenIdleState_returnsUnknownTime() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertThat(sessionPlayerConnector.getBufferedPosition()).isEqualTo(SessionPlayer.UNKNOWN_TIME); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getPlaybackSpeed_whenIdleState_throwsNoException() { + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + try { + sessionPlayerConnector.getPlaybackSpeed(); + } catch (Exception e) { + assertWithMessage(e.getMessage()).fail(); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withDataSourceCallback_changesPlayerState() throws Exception { + int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + + TestDataSourceCallback dataSource = + TestDataSourceCallback.fromAssetFd(resources.openRawResourceFd(resid)); + sessionPlayerConnector.setMediaItem(new CallbackMediaItem.Builder(dataSource).build()); + sessionPlayerConnector.prepare(); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + + // Test pause and restart. + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + assertThat(sessionPlayerConnector.getPlayerState()).isNotEqualTo(PLAYER_STATE_PLAYING); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_withNullMediaItem_throwsException() { + try { + sessionPlayerConnector.setMediaItem(null); + assertWithMessage("Null media item should be rejected").fail(); + } catch (NullPointerException e) { + // Expected exception + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_afterPlayback_remainsSame() throws Exception { + int resId1 = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + long start1 = 6_000; + long end1 = 7_000; + MediaItem mediaItem1 = + new UriMediaItem.Builder(TestUtils.createResourceUri(context, resId1)) + .setStartPosition(start1) + .setEndPosition(end1) + .build(); + + int resId2 = R.raw.testvideo; + long start2 = 3_000; + long end2 = 4_000; + MediaItem mediaItem2 = + new UriMediaItem.Builder(TestUtils.createResourceUri(context, resId2)) + .setStartPosition(start2) + .setEndPosition(end2) + .build(); + + List items = new ArrayList<>(); + items.add(mediaItem1); + items.add(mediaItem2); + sessionPlayerConnector.setPlaylist(items, null); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.prepare().get(); + + sessionPlayerConnector.setPlaybackSpeed(2.0f); + sessionPlayerConnector.play(); + + assertThat( + onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(mediaItem2); + assertThat(sessionPlayerConnector.getPlaybackSpeed()).isWithin(0.001f).of(2.0f); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_withSeriesOfSeek_succeeds() throws Exception { + int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + TestUtils.loadResource(context, resid, sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + List testSeekPositions = Arrays.asList(3000L, 2000L, 1000L); + for (long testSeekPosition : testSeekPositions) { + assertPlayerResultSuccess(sessionPlayerConnector.seekTo(testSeekPosition)); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(testSeekPosition); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_skipsUnnecessarySeek() throws Exception { + int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + TestDataSourceCallback source = + TestDataSourceCallback.fromAssetFd(resources.openRawResourceFd(resid)); + CountDownLatch readAllowedLatch = new CountDownLatch(1); + DataSourceCallback dataSource = + new DataSourceCallback() { + @Override + public int readAt(long position, byte[] buffer, int offset, int size) throws IOException { + try { + assertThat( + readAllowedLatch.await( + PLAYBACK_COMPLETED_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() throws IOException { + return source.getSize(); + } + + @Override + public void close() throws IOException { + source.close(); + } + }; + + sessionPlayerConnector.setMediaItem(new CallbackMediaItem.Builder(dataSource).build()); + + // prepare() will be pending until readAllowed is countDowned. + sessionPlayerConnector.prepare(); + + AtomicLong seekPosition = new AtomicLong(); + long testFinalSeekToPosition = 1000; + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + // Do not assert here, because onSeekCompleted() can be called after the player is + // closed. + seekPosition.set(position); + onSeekCompletedLatch.countDown(); + } + }); + + ListenableFuture seekFuture1 = sessionPlayerConnector.seekTo(3000); + ListenableFuture seekFuture2 = sessionPlayerConnector.seekTo(2000); + ListenableFuture seekFuture3 = + sessionPlayerConnector.seekTo(testFinalSeekToPosition); + + readAllowedLatch.countDown(); + + assertThat(seekFuture1.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED); + assertThat(seekFuture2.get().getResultCode()).isEqualTo(RESULT_INFO_SKIPPED); + assertThat(seekFuture3.get().getResultCode()).isEqualTo(RESULT_SUCCESS); + assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(seekPosition.get()).isEqualTo(testFinalSeekToPosition); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_whenUnderlyingPlayerAlsoSeeks_throwsNoException() throws Exception { + int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + TestUtils.loadResource(context, resid, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + List> futures = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + futures.add(sessionPlayerConnector.seekTo(4123)); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.seekTo(1243)); + } + + for (ListenableFuture future : futures) { + assertThat(future.get().getResultCode()) + .isAnyOf(PlayerResult.RESULT_INFO_SKIPPED, PlayerResult.RESULT_SUCCESS); + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_byUnderlyingPlayer_notifiesOnSeekCompleted() throws Exception { + int resid = R.raw.video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz; + TestUtils.loadResource(context, resid, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + long testSeekPosition = 1023; + AtomicLong seekPosition = new AtomicLong(); + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + // Do not assert here, because onSeekCompleted() can be called after the player is + // closed. + seekPosition.set(position); + onSeekCompletedLatch.countDown(); + } + }); + + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.seekTo(testSeekPosition)); + assertThat(onSeekCompletedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(seekPosition.get()).isEqualTo(testSeekPosition); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void getPlayerState_withCallingPrepareAndPlayAndPause_reflectsPlayerState() + throws Throwable { + TestUtils.loadResource(context, R.raw.testvideo, sessionPlayerConnector); + assertThat(sessionPlayerConnector.getBufferingState()) + .isEqualTo(SessionPlayer.BUFFERING_STATE_UNKNOWN); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + + assertThat(sessionPlayerConnector.getBufferingState()) + .isAnyOf( + SessionPlayer.BUFFERING_STATE_BUFFERING_AND_PLAYABLE, + SessionPlayer.BUFFERING_STATE_COMPLETE); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void prepare_twice_finishes() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResult(sessionPlayerConnector.prepare(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void prepare_notifiesOnPlayerStateChanged() throws Throwable { + TestUtils.loadResource( + context, + R.raw.video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz, + sessionPlayerConnector); + + CountDownLatch onPlayerStatePaused = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int state) { + if (state == SessionPlayer.PLAYER_STATE_PAUSED) { + onPlayerStatePaused.countDown(); + } + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertThat(onPlayerStatePaused.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void prepare_notifiesBufferingCompletedOnce() throws Throwable { + TestUtils.loadResource( + context, + R.raw.video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz, + sessionPlayerConnector); + + CountDownLatch onBufferingCompletedLatch = new CountDownLatch(2); + CopyOnWriteArrayList bufferingStateChanges = new CopyOnWriteArrayList<>(); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onBufferingStateChanged( + @NonNull SessionPlayer player, MediaItem item, int buffState) { + bufferingStateChanges.add(buffState); + if (buffState == SessionPlayer.BUFFERING_STATE_COMPLETE) { + onBufferingCompletedLatch.countDown(); + } + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertWithMessage( + "Expected BUFFERING_STATE_COMPLETE only once. Full changes are %s", + bufferingStateChanges) + .that( + onBufferingCompletedLatch.await( + PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isFalse(); + assertThat(bufferingStateChanges).isNotEmpty(); + int lastIndex = bufferingStateChanges.size() - 1; + assertWithMessage( + "Didn't end with BUFFERING_STATE_COMPLETE. Full changes are %s", bufferingStateChanges) + .that(bufferingStateChanges.get(lastIndex)) + .isEqualTo(SessionPlayer.BUFFERING_STATE_COMPLETE); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void seekTo_whenPrepared_notifiesOnSeekCompleted() throws Throwable { + long mp4DurationMs = 8_484L; + TestUtils.loadResource( + context, + R.raw.video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz, + sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onSeekCompletedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onSeekCompleted(@NonNull SessionPlayer player, long position) { + onSeekCompletedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.seekTo(mp4DurationMs >> 1); + + assertThat(onSeekCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_whenPrepared_notifiesOnPlaybackSpeedChanged() throws Throwable { + TestUtils.loadResource( + context, + R.raw.video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz, + sessionPlayerConnector); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaybackSpeedChangedLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback callback = + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaybackSpeedChanged(@NonNull SessionPlayer player, float speed) { + assertThat(speed).isWithin(FLOAT_TOLERANCE).of(0.5f); + onPlaybackSpeedChangedLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + sessionPlayerConnector.setPlaybackSpeed(0.5f); + + assertThat( + onPlaybackSpeedChangedLatch.await( + PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_withZeroSpeed_throwsException() { + try { + sessionPlayerConnector.setPlaybackSpeed(0.0f); + assertWithMessage("zero playback speed shouldn't be allowed").fail(); + } catch (IllegalArgumentException e) { + // expected. pass-through. + } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaybackSpeed_withNegativeSpeed_throwsException() { + try { + sessionPlayerConnector.setPlaybackSpeed(-1.0f); + assertWithMessage("negative playback speed isn't supported").fail(); + } catch (IllegalArgumentException e) { + // expected. pass-through. + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void close_throwsNoExceptionAndDoesNotCrash() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + sessionPlayerConnector.prepare(); + sessionPlayerConnector.play(); + sessionPlayerConnector.close(); + + // Set the player to null so we don't try to close it again in tearDown(). + sessionPlayerConnector = null; + + // Tests whether the notification from the player after the close() doesn't crash. + Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void cancelReturnedFuture_withSeekTo_cancelsPendingCommand() throws Exception { + CountDownLatch readRequestedLatch = new CountDownLatch(1); + CountDownLatch readAllowedLatch = new CountDownLatch(1); + // Need to wait from prepare() to counting down readAllowedLatch. + DataSourceCallback dataSource = + new DataSourceCallback() { + TestDataSourceCallback testSource = + TestDataSourceCallback.fromAssetFd(resources.openRawResourceFd(R.raw.testmp3)); + + @Override + public int readAt(long position, byte[] buffer, int offset, int size) throws IOException { + readRequestedLatch.countDown(); + try { + assertThat( + readAllowedLatch.await( + PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } catch (Exception e) { + assertWithMessage("Unexpected exception %s", e).fail(); + } + return testSource.readAt(position, buffer, offset, size); + } + + @Override + public long getSize() throws IOException { + return testSource.getSize(); + } + + @Override + public void close() { + testSource.close(); + } + }; + assertPlayerResultSuccess( + sessionPlayerConnector.setMediaItem(new CallbackMediaItem.Builder(dataSource).build())); + + // prepare() will be pending until readAllowed is countDowned. + ListenableFuture prepareFuture = sessionPlayerConnector.prepare(); + ListenableFuture seekFuture = sessionPlayerConnector.seekTo(1000); + + assertThat(readRequestedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + + // Cancel the pending commands while preparation is on hold. + seekFuture.cancel(false); + + // Make the on-going prepare operation resumed and finished. + readAllowedLatch.countDown(); + assertPlayerResultSuccess(prepareFuture); + + // Check whether the canceled seek() didn't happened. + // Checking seekFuture.get() will be useless because it always throws CancellationException due + // to the CallbackToFuture implementation. + Thread.sleep(PLAYER_STATE_CHANGE_WAIT_TIME_MS); + assertThat(sessionPlayerConnector.getCurrentPosition()).isEqualTo(0); + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withNullPlaylist_throwsException() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + try { + sessionPlayerConnector.setPlaylist(null, null); + assertWithMessage("null playlist shouldn't be allowed").fail(); + } catch (Exception e) { + // pass-through + } + } + + @Test + @SmallTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withPlaylistContainingNullItem_throwsException() { + try { + List list = new ArrayList<>(); + list.add(null); + sessionPlayerConnector.setPlaylist(list, null); + assertWithMessage("playlist with null item shouldn't be allowed").fail(); + } catch (Exception e) { + // pass-through + } + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_setsPlaylistAndCurrentMediaItem() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch)); + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null)); + assertThat( + onCurrentMediaItemChangedLatch.await( + PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + + assertThat(sessionPlayerConnector.getPlaylist()).isEqualTo(playlist); + assertThat(sessionPlayerConnector.getCurrentMediaItem()).isEqualTo(playlist.get(0)); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + + sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null); + sessionPlayerConnector.prepare(); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void addPlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int addIndex = 2; + MediaItem newMediaItem = TestUtils.createMediaItem(context); + playlist.add(addIndex, newMediaItem); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.addPlaylistItem(addIndex, newMediaItem); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void removePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int removeIndex = 3; + playlist.remove(removeIndex); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.removePlaylistItem(removeIndex); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void replacePlaylistItem_calledOnlyOnce_notifiesPlaylistChangeOnlyOnce() throws Exception { + List playlist = TestUtils.createPlaylist(context, 10); + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPlaylistChangedLatch = new CountDownLatch(2); + int replaceIndex = 2; + MediaItem newMediaItem = TestUtils.createMediaItem(context); + playlist.set(replaceIndex, newMediaItem); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + assertThat(list).isEqualTo(playlist); + onPlaylistChangedLatch.countDown(); + } + }); + sessionPlayerConnector.replacePlaylistItem(replaceIndex, newMediaItem); + assertThat(onPlaylistChangedLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isFalse(); + assertThat(onPlaylistChangedLatch.getCount()).isEqualTo(1); + } + + @Test + @LargeTest + @Ignore("setMediaItem() is currently implemented with setPlaylist(), so list isn't empty.") + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setMediaItem_afterSettingPlaylist_notifiesOnPlaylistChangedWithNullList() + throws Exception { + List playlist = TestUtils.createPlaylist(context, /* size= */ 10); + CountDownLatch onPlaylistBecomesNullLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlaylistChanged( + @NonNull SessionPlayer player, + @Nullable List list, + @Nullable MediaMetadata metadata) { + if (list == null) { + onPlaylistBecomesNullLatch.countDown(); + } + } + }); + sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null); + sessionPlayerConnector.setMediaItem(playlist.get(0)); + assertThat( + onPlaylistBecomesNullLatch.await(PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setPlaylist_withPlaylist_notifiesOnCurrentMediaItemChanged() throws Exception { + int listSize = 2; + List playlist = TestUtils.createPlaylist(context, listSize); + + CountDownLatch onCurrentMediaItemChangedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch)); + + assertPlayerResultSuccess(sessionPlayerConnector.setPlaylist(playlist, null)); + assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat( + onCurrentMediaItemChangedLatch.await( + PLAYLIST_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_twice_finishes() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertPlayerResult(sessionPlayerConnector.play(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_withPlaylist_notifiesOnCurrentMediaItemChangedAndOnPlaybackCompleted() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(context, R.raw.number1)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number2)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number3)); + + CountDownLatch onPlaybackCompletedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + int currentMediaItemChangedCount = 0; + + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + + int currentIdx = player.getCurrentMediaItemIndex(); + int expectedCurrentIdx = currentMediaItemChangedCount++; + assertThat(currentIdx).isEqualTo(expectedCurrentIdx); + assertThat(item).isEqualTo(playlist.get(expectedCurrentIdx)); + } + + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + onPlaybackCompletedLatch.countDown(); + } + }); + + assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull(); + assertThat(sessionPlayerConnector.prepare()).isNotNull(); + assertThat(sessionPlayerConnector.play()).isNotNull(); + + assertThat( + onPlaybackCompletedLatch.await(PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void play_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + CountDownLatch onPlayingLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PLAYING) { + onPlayingLatch.countDown(); + } + } + }); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(true)); + + assertThat(onPlayingLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_twice_finishes() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3, sessionPlayerConnector); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertPlayerResultSuccess(sessionPlayerConnector.pause()); + assertPlayerResult(sessionPlayerConnector.pause(), RESULT_INFO_SKIPPED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_byUnderlyingPlayer_notifiesOnPlayerStateChanges() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + CountDownLatch onPausedLatch = new CountDownLatch(1); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + if (playerState == PLAYER_STATE_PAUSED) { + onPausedLatch.countDown(); + } + } + }); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync(() -> simpleExoPlayer.setPlayWhenReady(false)); + + assertThat(onPausedLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void pause_byUnderlyingPlayerInListener_changesToPlayerStatePaused() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3_2, sessionPlayerConnector); + SimpleExoPlayer simpleExoPlayer = playerTestRule.getSimpleExoPlayer(); + + CountDownLatch playerStateChangesLatch = new CountDownLatch(3); + CopyOnWriteArrayList playerStateChanges = new CopyOnWriteArrayList<>(); + sessionPlayerConnector.registerPlayerCallback( + executor, + new SessionPlayer.PlayerCallback() { + @Override + public void onPlayerStateChanged(@NonNull SessionPlayer player, int playerState) { + playerStateChanges.add(playerState); + playerStateChangesLatch.countDown(); + } + }); + + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + InstrumentationRegistry.getInstrumentation() + .runOnMainSync( + () -> { + simpleExoPlayer.addListener( + new Player.EventListener() { + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { + if (playWhenReady) { + simpleExoPlayer.setPlayWhenReady(false); + } + } + }); + }); + + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat( + playerStateChangesLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + assertThat(playerStateChanges) + .containsExactly( + PLAYER_STATE_PAUSED, // After prepare() + PLAYER_STATE_PLAYING, // After play() + PLAYER_STATE_PAUSED) // After setPlayWhenREady(false) + .inOrder(); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PAUSED); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void skipToNextAndPrevious_calledInARow_notifiesOnCurrentMediaItemChanged() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(context, R.raw.number1)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number2)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number3)); + assertThat(sessionPlayerConnector.setPlaylist(playlist, /* metadata= */ null)).isNotNull(); + + // STEP 1: prepare() + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + + // STEP 2: skipToNextPlaylistItem() + CountDownLatch onNextMediaItemLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback skipToNextTestCallback = + new SessionPlayer.PlayerCallback() { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + int expectedIndex = 1; + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(expectedIndex); + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + assertThat(item).isEqualTo(playlist.get(expectedIndex)); + onNextMediaItemLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, skipToNextTestCallback); + assertPlayerResultSuccess(sessionPlayerConnector.skipToNextPlaylistItem()); + assertThat(onNextMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + sessionPlayerConnector.unregisterPlayerCallback(skipToNextTestCallback); + + // STEP 3: skipToPreviousPlaylistItem() + CountDownLatch onPreviousMediaItemLatch = new CountDownLatch(1); + SessionPlayer.PlayerCallback skipToPreviousTestCallback = + new SessionPlayer.PlayerCallback() { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + int expectedIndex = 0; + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(expectedIndex); + assertThat(item).isEqualTo(player.getCurrentMediaItem()); + assertThat(item).isEqualTo(playlist.get(expectedIndex)); + onPreviousMediaItemLatch.countDown(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, skipToPreviousTestCallback); + assertPlayerResultSuccess(sessionPlayerConnector.skipToPreviousPlaylistItem()); + assertThat( + onPreviousMediaItemLatch.await(PLAYER_STATE_CHANGE_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + sessionPlayerConnector.unregisterPlayerCallback(skipToPreviousTestCallback); + } + + @Test + @LargeTest + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.KITKAT) + public void setRepeatMode_withRepeatAll_continuesToPlayPlaylistWithoutBeingCompleted() + throws Exception { + List playlist = new ArrayList<>(); + playlist.add(TestUtils.createMediaItem(context, R.raw.number1)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number2)); + playlist.add(TestUtils.createMediaItem(context, R.raw.number3)); + int listSize = playlist.size(); + + // Any value more than list size + 1, to see repeat mode with the recorded video. + int expectedCurrentMediaItemChanges = listSize + 2; + CountDownLatch onCurrentMediaItemChangedLatch = + new CountDownLatch(expectedCurrentMediaItemChanges); + CopyOnWriteArrayList currentMediaItemChanges = new CopyOnWriteArrayList<>(); + PlayerCallbackForPlaylist callback = + new PlayerCallbackForPlaylist(playlist, onCurrentMediaItemChangedLatch) { + @Override + public void onCurrentMediaItemChanged( + @NonNull SessionPlayer player, @NonNull MediaItem item) { + super.onCurrentMediaItemChanged(player, item); + currentMediaItemChanges.add(item); + onCurrentMediaItemChangedLatch.countDown(); + } + + @Override + public void onPlaybackCompleted(@NonNull SessionPlayer player) { + assertWithMessage( + "Playback shouldn't be completed, Actual changes were %s", + currentMediaItemChanges) + .fail(); + } + }; + sessionPlayerConnector.registerPlayerCallback(executor, callback); + + assertThat(sessionPlayerConnector.setPlaylist(playlist, null)).isNotNull(); + assertThat(sessionPlayerConnector.prepare()).isNotNull(); + assertThat(sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL)).isNotNull(); + assertThat(sessionPlayerConnector.play()).isNotNull(); + + assertWithMessage( + "Current media item didn't change as expected. Actual changes were %s", + currentMediaItemChanges) + .that( + onCurrentMediaItemChangedLatch.await( + PLAYBACK_COMPLETED_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + + int expectedMediaItemIndex = 0; + for (MediaItem mediaItemInPlaybackOrder : currentMediaItemChanges) { + assertWithMessage( + "Unexpected media item for %sth playback. Actual changes were %s", + expectedMediaItemIndex, currentMediaItemChanges) + .that(mediaItemInPlaybackOrder) + .isEqualTo(playlist.get(expectedMediaItemIndex)); + expectedMediaItemIndex = (expectedMediaItemIndex + 1) % listSize; + } + } + + @Test + @LargeTest + public void getPlayerState_withPrepareAndPlayAndPause_changesAsExpected() throws Exception { + TestUtils.loadResource(context, R.raw.testmp3, sessionPlayerConnector); + + AudioAttributesCompat attributes = + new AudioAttributesCompat.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build(); + sessionPlayerConnector.setAudioAttributes(attributes); + sessionPlayerConnector.setRepeatMode(SessionPlayer.REPEAT_MODE_ALL); + + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(SessionPlayer.PLAYER_STATE_IDLE); + assertPlayerResultSuccess(sessionPlayerConnector.prepare()); + assertThat(sessionPlayerConnector.getPlayerState()) + .isEqualTo(SessionPlayer.PLAYER_STATE_PAUSED); + assertPlayerResultSuccess(sessionPlayerConnector.play()); + assertThat(sessionPlayerConnector.getPlayerState()).isEqualTo(PLAYER_STATE_PLAYING); + } + + private class PlayerCallbackForPlaylist extends SessionPlayer.PlayerCallback { + private List playlist; + private CountDownLatch onCurrentMediaItemChangedLatch; + + PlayerCallbackForPlaylist(List playlist, CountDownLatch latch) { + this.playlist = playlist; + onCurrentMediaItemChangedLatch = latch; + } + + @Override + public void onCurrentMediaItemChanged(@NonNull SessionPlayer player, @NonNull MediaItem item) { + int currentIdx = playlist.indexOf(item); + assertThat(sessionPlayerConnector.getCurrentMediaItemIndex()).isEqualTo(currentIdx); + onCurrentMediaItemChangedLatch.countDown(); + } + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestDataSourceCallback.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestDataSourceCallback.java new file mode 100644 index 0000000000..6834ef7ece --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestDataSourceCallback.java @@ -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()"); + } +} diff --git a/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java new file mode 100644 index 0000000000..8d564631b7 --- /dev/null +++ b/extensions/media2/src/androidTest/java/com/google/android/exoplayer2/ext/media2/TestUtils.java @@ -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 createPlaylist(Context context, int size) { + List 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 future) throws Exception { + assertPlayerResult(future, RESULT_SUCCESS); + } + + public static void assertPlayerResult( + Future 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. + } +} diff --git a/extensions/media2/src/androidTest/res/layout/mediaplayer.xml b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml new file mode 100644 index 0000000000..1861e5e44e --- /dev/null +++ b/extensions/media2/src/androidTest/res/layout/mediaplayer.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/extensions/media2/src/androidTest/res/raw/number1.mp4 b/extensions/media2/src/androidTest/res/raw/number1.mp4 new file mode 100644 index 0000000000..b8d9236def Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/number1.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/number2.mp4 b/extensions/media2/src/androidTest/res/raw/number2.mp4 new file mode 100644 index 0000000000..c29d88c21f Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/number2.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/number3.mp4 b/extensions/media2/src/androidTest/res/raw/number3.mp4 new file mode 100644 index 0000000000..767bd5c647 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/number3.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/sample_not_seekable.ts b/extensions/media2/src/androidTest/res/raw/sample_not_seekable.ts new file mode 100644 index 0000000000..d148c6d7e0 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/sample_not_seekable.ts differ diff --git a/extensions/media2/src/androidTest/res/raw/testmp3.mp3 b/extensions/media2/src/androidTest/res/raw/testmp3.mp3 new file mode 100755 index 0000000000..657faf7718 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/testmp3.mp3 differ diff --git a/extensions/media2/src/androidTest/res/raw/testmp3_2.mp3 b/extensions/media2/src/androidTest/res/raw/testmp3_2.mp3 new file mode 100644 index 0000000000..6a70c69c9d Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/testmp3_2.mp3 differ diff --git a/extensions/media2/src/androidTest/res/raw/testvideo.3gp b/extensions/media2/src/androidTest/res/raw/testvideo.3gp new file mode 100644 index 0000000000..c51f109f97 Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/testvideo.3gp differ diff --git a/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz.mp4 b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz.mp4 new file mode 100644 index 0000000000..571ff4459d Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1000kbps_30fps_aac_stereo_128kbps_44100hz.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_128kbps_44100hz.mp4 b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_128kbps_44100hz.mp4 new file mode 100644 index 0000000000..36cd1b1b5b Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_128kbps_44100hz.mp4 differ diff --git a/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz.mp4 b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz.mp4 new file mode 100644 index 0000000000..63e25b833d Binary files /dev/null and b/extensions/media2/src/androidTest/res/raw/video_480x360_mp4_h264_1350kbps_30fps_aac_stereo_192kbps_44100hz.mp4 differ diff --git a/extensions/media2/src/main/AndroidManifest.xml b/extensions/media2/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3b87ee9dfa --- /dev/null +++ b/extensions/media2/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java new file mode 100644 index 0000000000..6731fad4c0 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/ConcatenatingMediaSourcePlaybackPreparer.java @@ -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); + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DataSourceCallbackDataSource.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DataSourceCallbackDataSource.java new file mode 100644 index 0000000000..ac01321464 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/DataSourceCallbackDataSource.java @@ -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(); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java new file mode 100644 index 0000000000..6d053174aa --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/MediaSessionUtil.java @@ -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. + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java new file mode 100644 index 0000000000..0278b9077c --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerCommandQueue.java @@ -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 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 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 addCommand( + @CommandCode int commandCode, Callable command) { + return addCommand(commandCode, command, /* tag= */ null); + } + + public ListenableFuture addCommand( + @CommandCode int commandCode, Callable command, @Nullable Object tag) { + SettableFuture 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 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 result; + + public AsyncPlayerCommandResult( + @AsyncCommandCode int commandCode, SettableFuture 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 command; + // Result shouldn't be set with lock held, because it may trigger listener set by developers. + public final SettableFuture result; + @Nullable private final Object tag; + + public PlayerCommand( + int commandCode, + Callable command, + SettableFuture 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(); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerHandler.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerHandler.java new file mode 100644 index 0000000000..eca8964804 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerHandler.java @@ -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; + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java new file mode 100644 index 0000000000..1794b8ccb5 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerWrapper.java @@ -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. + * + *

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 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 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 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(); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlaylistManager.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlaylistManager.java new file mode 100644 index 0000000000..0eab1bc3a3 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlaylistManager.java @@ -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 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 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); +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java new file mode 100644 index 0000000000..98b0e8554e --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallback.java @@ -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 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 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 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 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 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); + } + } + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java new file mode 100644 index 0000000000..e334dbd0ad --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionCallbackBuilder.java @@ -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. + * + *

If it returns {@code true} to accept connection, then {@link #getAllowedCommands} will be + * immediately followed to return initial allowed command. + * + *

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: + * + *

    + *
  • A {@link MediaController} requests to connect, and allowed commands is required to tell + * initial allowed commands. + *
  • Underlying {@link SessionPlayer} state changes, and allowed commands may be updated via + * {@link MediaSession#setAllowedCommands}. + *
+ * + *

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: + * + *

    + *
  • Accepts connection requests from controller if any of the following conditions are met: + *
      + *
    • Controller is in the same package as the session. + *
    • Controller is allowed via {@link #setTrustedPackageNames(List)}. + *
    • Controller has package name {@link RemoteUserInfo#LEGACY_CONTROLLER}. See {@link + * ControllerInfo#getPackageName() package name limitation} for details. + *
    • Controller is trusted (i.e. has MEDIA_CONTENT_CONTROL permission or has enabled + * notification manager). + *
    + *
  • Allows all commands that the current player can handle. + *
  • Accepts all command requests for allowed commands. + *
+ * + *

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 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. + * + *

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 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. + * + *

The builder uses the following default values: + * + *

    + *
  • {@link AllowedCommandProvider}: {@link DefaultAllowedCommandProvider} + *
  • Seek timeout: {@link #DEFAULT_SEEK_TIMEOUT_MS} + *
  • + *
+ * + * 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); + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java new file mode 100644 index 0000000000..f3cb937830 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SessionPlayerConnector.java @@ -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. + * + *

Ownership

+ * + *

{@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). + * + *

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()}. + * + *

Threading model

+ * + *

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 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 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.runPlayerCallableBlocking( + /* callable= */ () -> { + playerWrapper.reset(); + return null; + }); + } + + @Override + public ListenableFuture play() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PLAY, /* command= */ () -> player.play()); + } + + @Override + public ListenableFuture pause() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PAUSE, /* command= */ () -> player.pause()); + } + + @Override + public ListenableFuture prepare() { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_PREPARE, /* command= */ () -> player.prepare()); + } + + @Override + public ListenableFuture seekTo(long position) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SEEK_TO, + /* command= */ () -> player.seekTo(position), + /* tag= */ position); + } + + @Override + public ListenableFuture 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 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 setMediaItem(MediaItem item) { + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_MEDIA_ITEM, () -> player.setMediaItem(item)); + result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture setPlaylist( + final List 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 result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_PLAYLIST, + /* command= */ () -> player.setPlaylist(playlist, metadata)); + result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture addPlaylistItem(int index, MediaItem item) { + Assertions.checkArgument(index >= 0); + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + ListenableFuture 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 removePlaylistItem(@IntRange(from = 0) int index) { + Assertions.checkArgument(index >= 0); + ListenableFuture result = + playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM, + /* command= */ () -> player.removePlaylistItem(index)); + result.addListener(this::handlePlaylistChangedOnHandler, taskHandlerExecutor); + return result; + } + + @Override + public ListenableFuture replacePlaylistItem(int index, MediaItem item) { + Assertions.checkArgument(index >= 0); + Assertions.checkNotNull(item); + Assertions.checkArgument(!(item instanceof FileMediaItem)); + ListenableFuture 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 skipToPreviousPlaylistItem() { + ListenableFuture 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 skipToNextPlaylistItem() { + ListenableFuture 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 skipToPlaylistItem(@IntRange(from = 0) int index) { + Assertions.checkArgument(index >= 0); + ListenableFuture 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 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 setRepeatMode(int repeatMode) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_REPEAT_MODE, + /* command= */ () -> player.setRepeatMode(repeatMode)); + } + + @Override + public ListenableFuture setShuffleMode(int shuffleMode) { + return playerCommandQueue.addCommand( + PlayerCommandQueue.COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE, + /* command= */ () -> player.setShuffleMode(shuffleMode)); + } + + @Override + @Nullable + public List 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.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()}. + * + *

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.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> callbacks = getCallbacks(); + for (Pair pair : callbacks) { + SessionPlayer.PlayerCallback callback = Assertions.checkNotNull(pair.first); + Executor executor = Assertions.checkNotNull(pair.second); + executor.execute(() -> notifier.callCallback(callback)); + } + } + + private void handlePlaylistChangedOnHandler() { + List 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 runPlayerCallableBlocking(Callable callable) { + synchronized (stateLock) { + Assertions.checkState(!closed); + } + return runPlayerCallableBlockingInternal(callable); + } + + private T runPlayerCallableBlockingInternal(Callable callable) { + SettableFuture 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 runPlayerCallableBlockingWithNullOnException(Callable<@NullableType T> callable) { + try { + return runPlayerCallableBlocking(callable); + } catch (Exception e) { + return null; + } + } + + private T runPlayerCallableBlocking(Callable 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)); + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java new file mode 100644 index 0000000000..01a30682ae --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/SettableFuture.java @@ -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 implements ListenableFuture { + static SettableFuture create() { + return new SettableFuture<>(); + } + + private final ListenableFuture future; + private final CallbackToFutureAdapter.Completer completer; + + SettableFuture() { + AtomicReference> 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); + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/TimelinePlaylistManager.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/TimelinePlaylistManager.java new file mode 100644 index 0000000000..e188d3ce59 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/TimelinePlaylistManager.java @@ -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}. + * + *

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 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}. + * + *

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 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 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 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 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; + } + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java new file mode 100644 index 0000000000..d3e90a1c34 --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/Utils.java @@ -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 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. + } +} diff --git a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java new file mode 100644 index 0000000000..4003847b3f --- /dev/null +++ b/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/package-info.java @@ -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; diff --git a/extensions/media2/src/main/proguard-rules.txt b/extensions/media2/src/main/proguard-rules.txt new file mode 120000 index 0000000000..499fb08b36 --- /dev/null +++ b/extensions/media2/src/main/proguard-rules.txt @@ -0,0 +1 @@ +../../proguard-rules.txt \ No newline at end of file