diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 6bc677da40..eb3fcf298b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -137,7 +137,7 @@ * Recreate the decoder when handling and swallowing decode errors in `TextRenderer`. This fixes a case where playback would never end when playing content with malformed subtitles - ([#7590](https://github.com/google/ExoPlayer/issues/790)). + ([#7590](https://github.com/google/ExoPlayer/issues/7590)). * Only apply `CaptionManager` font scaling in `SubtitleView.setUserDefaultTextSize` if the `CaptionManager` is enabled. @@ -315,6 +315,8 @@ * Add `ImaAdsLoader.Builder.setCompanionAdSlots` so it's possible to set companion ad slots without accessing the `AdDisplayContainer`. * Add missing notification of `VideoAdPlayerCallback.onLoaded`. + * Fix handling of incompatible VPAID ads + ([#7832](https://github.com/google/ExoPlayer/issues/7832)). * Demo app: * Replace the `extensions` variant with `decoderExtensions` and update the demo app use the Cronet and IMA extensions by default. diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java index 8109263e55..b448dd40de 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/DownloadTracker.java @@ -16,10 +16,12 @@ package com.google.android.exoplayer2.demo; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; import android.content.Context; import android.content.DialogInterface; import android.net.Uri; +import android.os.AsyncTask; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -170,6 +172,7 @@ public class DownloadTracker { private TrackSelectionDialog trackSelectionDialog; private MappedTrackInfo mappedTrackInfo; + private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask; @Nullable private byte[] keySetId; public StartDownloadDialogHelper( @@ -185,6 +188,9 @@ public class DownloadTracker { if (trackSelectionDialog != null) { trackSelectionDialog.dismiss(); } + if (widevineOfflineLicenseFetchTask != null) { + widevineOfflineLicenseFetchTask.cancel(false); + } } // DownloadHelper.Callback implementation. @@ -192,59 +198,32 @@ public class DownloadTracker { @Override public void onPrepared(@NonNull DownloadHelper helper) { @Nullable Format format = getFirstFormatWithDrmInitData(helper); - if (format != null) { - if (Util.SDK_INT < 18) { - Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) - .show(); - Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); - return; - } - // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. - if (!hasSchemaData(format.drmInitData)) { - Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) - .show(); - Log.e( - TAG, - "Downloading content where DRM scheme data is not located in the manifest is not" - + " supported"); - return; - } - try { - // TODO(internal b/163107948): Download the license on another thread to keep the UI - // thread unblocked. - fetchOfflineLicense(format); - } catch (DrmSession.DrmSessionException e) { - Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) - .show(); - Log.e(TAG, "Failed to fetch offline DRM license", e); - return; - } - } - - if (helper.getPeriodCount() == 0) { - Log.d(TAG, "No periods found. Downloading entire stream."); - startDownload(); - downloadHelper.release(); + if (format == null) { + onDownloadPrepared(helper); return; } - mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); - if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { - Log.d(TAG, "No dialog content. Downloading entire stream."); - startDownload(); - downloadHelper.release(); + // The content is DRM protected. We need to acquire an offline license. + if (Util.SDK_INT < 18) { + Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); return; } - trackSelectionDialog = - TrackSelectionDialog.createForMappedTrackInfoAndParameters( - /* titleId= */ R.string.exo_download_description, - mappedTrackInfo, - trackSelectorParameters, - /* allowAdaptiveSelections =*/ false, - /* allowMultipleOverrides= */ true, - /* onClickListener= */ this, - /* onDismissListener= */ this); - trackSelectionDialog.show(fragmentManager, /* tag= */ null); + // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. + if (!hasSchemaData(format.drmInitData)) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e( + TAG, + "Downloading content where DRM scheme data is not located in the manifest is not" + + " supported"); + return; + } + widevineOfflineLicenseFetchTask = + new WidevineOfflineLicenseFetchTask( + format, mediaItem.playbackProperties.drmConfiguration.licenseUri, this, helper); + widevineOfflineLicenseFetchTask.execute(); } @Override @@ -292,6 +271,44 @@ public class DownloadTracker { // Internal methods. + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { + this.keySetId = keySetId; + onDownloadPrepared(helper); + } + + private void onOfflineLicenseFetchedError(DrmSession.DrmSessionException e) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Failed to fetch offline DRM license", e); + } + + private void onDownloadPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() == 0) { + Log.d(TAG, "No periods found. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + + mappedTrackInfo = downloadHelper.getMappedTrackInfo(/* periodIndex= */ 0); + if (!TrackSelectionDialog.willHaveContent(mappedTrackInfo)) { + Log.d(TAG, "No dialog content. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + trackSelectionDialog = + TrackSelectionDialog.createForMappedTrackInfoAndParameters( + /* titleId= */ R.string.exo_download_description, + mappedTrackInfo, + trackSelectorParameters, + /* allowAdaptiveSelections =*/ false, + /* allowMultipleOverrides= */ true, + /* onClickListener= */ this, + /* onDismissListener= */ this); + trackSelectionDialog.show(fragmentManager, /* tag= */ null); + } + private void startDownload() { startDownload(buildDownloadRequest()); } @@ -306,15 +323,54 @@ public class DownloadTracker { .getDownloadRequest(Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title))) .copyWithKeySetId(keySetId); } + } - @RequiresApi(18) - private void fetchOfflineLicense(Format format) throws DrmSession.DrmSessionException { + /** Downloads a Widevine offline license in a background thread. */ + @RequiresApi(18) + private final class WidevineOfflineLicenseFetchTask extends AsyncTask { + private final Format format; + private final Uri licenseUri; + private final StartDownloadDialogHelper dialogHelper; + private final DownloadHelper downloadHelper; + + @Nullable private byte[] keySetId; + @Nullable private DrmSession.DrmSessionException drmSessionException; + + public WidevineOfflineLicenseFetchTask( + Format format, + Uri licenseUri, + StartDownloadDialogHelper dialogHelper, + DownloadHelper downloadHelper) { + this.format = format; + this.licenseUri = licenseUri; + this.dialogHelper = dialogHelper; + this.downloadHelper = downloadHelper; + } + + @Override + protected Void doInBackground(Void... voids) { OfflineLicenseHelper offlineLicenseHelper = OfflineLicenseHelper.newWidevineInstance( - mediaItem.playbackProperties.drmConfiguration.licenseUri.toString(), + licenseUri.toString(), httpDataSourceFactory, new DrmSessionEventListener.EventDispatcher()); - keySetId = offlineLicenseHelper.downloadLicense(format); + try { + keySetId = offlineLicenseHelper.downloadLicense(format); + } catch (DrmSession.DrmSessionException e) { + drmSessionException = e; + } finally { + offlineLicenseHelper.release(); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (drmSessionException != null) { + dialogHelper.onOfflineLicenseFetchedError(drmSessionException); + } else { + dialogHelper.onOfflineLicenseFetched(downloadHelper, checkStateNotNull(keySetId)); + } } } diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index 797eb503dd..370db4ac70 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -271,13 +271,14 @@ public class PlayerActivity extends AppCompatActivity setContentView(R.layout.player_activity); } - protected void initializePlayer() { + /** @return Whether initialization was successful. */ + protected boolean initializePlayer() { if (player == null) { Intent intent = getIntent(); mediaItems = createMediaItems(intent); if (mediaItems.isEmpty()) { - return; + return false; } boolean preferExtensionDecoders = @@ -297,9 +298,9 @@ public class PlayerActivity extends AppCompatActivity .setTrackSelector(trackSelector) .build(); player.addListener(new PlayerEventListener()); + player.addAnalyticsListener(new EventLogger(trackSelector)); player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true); player.setPlayWhenReady(startAutoPlay); - player.addAnalyticsListener(new EventLogger(trackSelector)); playerView.setPlayer(player); playerView.setPlaybackPreparer(this); debugViewHelper = new DebugTextViewHelper(player, debugTextView); @@ -312,6 +313,7 @@ public class PlayerActivity extends AppCompatActivity player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); player.prepare(); updateButtonVisibility(); + return true; } private List createMediaItems(Intent intent) { @@ -548,17 +550,7 @@ public class PlayerActivity extends AppCompatActivity @Nullable DownloadRequest downloadRequest = downloadTracker.getDownloadRequest(checkNotNull(item.playbackProperties).uri); - if (downloadRequest != null) { - MediaItem mediaItem = - item.buildUpon() - .setStreamKeys(downloadRequest.streamKeys) - .setCustomCacheKey(downloadRequest.customCacheKey) - .setDrmKeySetId(downloadRequest.keySetId) - .build(); - mediaItems.add(mediaItem); - } else { - mediaItems.add(item); - } + mediaItems.add(downloadRequest != null ? downloadRequest.toMediaItem() : item); } return mediaItems; } diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java index 81e21bc753..351ad43d2c 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoader.java @@ -1096,6 +1096,11 @@ public final class ImaAdsLoader if (imaAdInfo != null) { adPlaybackState = adPlaybackState.withSkippedAdGroup(imaAdInfo.adGroupIndex); updateAdPlaybackState(); + } else if (adPlaybackState.adGroupCount == 1 && adPlaybackState.adGroupTimesUs[0] == 0) { + // For incompatible VPAID ads with one preroll, content is resumed immediately. In this case + // we haven't received ad info (the ad never loaded), but there is only one ad group to skip. + adPlaybackState = adPlaybackState.withSkippedAdGroup(/* adGroupIndex= */ 0); + updateAdPlaybackState(); } } diff --git a/extensions/jobdispatcher/README.md b/extensions/jobdispatcher/README.md index 613277bad2..9e26c07c5d 100644 --- a/extensions/jobdispatcher/README.md +++ b/extensions/jobdispatcher/README.md @@ -1,12 +1,10 @@ # ExoPlayer Firebase JobDispatcher extension # -**DEPRECATED - Please use [WorkManager extension][] or [PlatformScheduler][] -instead.** +**This extension is deprecated. Use the [WorkManager extension][] instead.** This extension provides a Scheduler implementation which uses [Firebase JobDispatcher][]. [WorkManager extension]: https://github.com/google/ExoPlayer/blob/release-v2/extensions/workmanager/README.md -[PlatformScheduler]: https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/scheduler/PlatformScheduler.java [Firebase JobDispatcher]: https://github.com/firebase/firebase-jobdispatcher-android ## Getting the extension ## 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 index 45a0c59645..b80cbe5a5f 100644 --- 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 @@ -185,17 +185,20 @@ public class SessionPlayerConnectorTest { } }; SimpleExoPlayer simpleExoPlayer = null; + SessionPlayerConnector playerConnector = null; try { simpleExoPlayer = new SimpleExoPlayer.Builder(context) .setLooper(Looper.myLooper()) .build(); - try (SessionPlayerConnector player = - new SessionPlayerConnector( - simpleExoPlayer, new DefaultMediaItemConverter(), controlDispatcher)) { - assertPlayerResult(player.play(), RESULT_INFO_SKIPPED); - } + playerConnector = + new SessionPlayerConnector(simpleExoPlayer, new DefaultMediaItemConverter()); + playerConnector.setControlDispatcher(controlDispatcher); + assertPlayerResult(playerConnector.play(), RESULT_INFO_SKIPPED); } finally { + if (playerConnector != null) { + playerConnector.close(); + } if (simpleExoPlayer != null) { simpleExoPlayer.release(); } 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 index 7d804282ce..fc80c85856 100644 --- 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 @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.ext.media2; +import static com.google.android.exoplayer2.util.Util.postOrRun; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import android.os.Handler; import androidx.annotation.GuardedBy; import androidx.annotation.IntDef; import androidx.annotation.Nullable; @@ -129,7 +131,7 @@ import java.util.concurrent.Callable; // Should be only used on the handler. private final PlayerWrapper player; - private final PlayerHandler handler; + private final Handler handler; private final Object lock; @GuardedBy("lock") @@ -141,7 +143,7 @@ import java.util.concurrent.Callable; // Should be only used on the handler. @Nullable private AsyncPlayerCommandResult pendingAsyncPlayerCommandResult; - public PlayerCommandQueue(PlayerWrapper player, PlayerHandler handler) { + public PlayerCommandQueue(PlayerWrapper player, Handler handler) { this.player = player; this.handler = handler; lock = new Object(); @@ -209,7 +211,7 @@ import java.util.concurrent.Callable; } processPendingCommandOnHandler(); }, - handler::postOrRun); + (runnable) -> postOrRun(handler, runnable)); if (DEBUG) { Log.d(TAG, "adding " + playerCommand); } @@ -220,7 +222,8 @@ import java.util.concurrent.Callable; } public void notifyCommandError() { - handler.postOrRun( + postOrRun( + handler, () -> { @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult; if (pendingResult == null) { @@ -243,7 +246,8 @@ import java.util.concurrent.Callable; if (DEBUG) { Log.d(TAG, "notifyCommandCompleted, completedCommandCode=" + completedCommandCode); } - handler.postOrRun( + postOrRun( + handler, () -> { @Nullable AsyncPlayerCommandResult pendingResult = pendingAsyncPlayerCommandResult; if (pendingResult == null || pendingResult.commandCode != completedCommandCode) { @@ -267,7 +271,7 @@ import java.util.concurrent.Callable; } private void processPendingCommand() { - handler.postOrRun(this::processPendingCommandOnHandler); + postOrRun(handler, this::processPendingCommandOnHandler); } private void processPendingCommandOnHandler() { 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 deleted file mode 100644 index eca8964804..0000000000 --- a/extensions/media2/src/main/java/com/google/android/exoplayer2/ext/media2/PlayerHandler.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 index 453a7b6d55..09e0325e93 100644 --- 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 @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.ext.media2; +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.core.util.ObjectsCompat; @@ -24,6 +27,7 @@ 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.DefaultControlDispatcher; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; @@ -100,12 +104,11 @@ import java.util.List; private static final int POLL_BUFFER_INTERVAL_MS = 1000; private final Listener listener; - private final PlayerHandler handler; + private final Handler handler; private final Runnable pollBufferRunnable; private final Player player; private final MediaItemConverter mediaItemConverter; - private final ControlDispatcher controlDispatcher; private final ComponentListener componentListener; @Nullable private MediaMetadata playlistMetadata; @@ -114,6 +117,7 @@ import java.util.List; private final List media2Playlist; private final List exoPlayerPlaylist; + private ControlDispatcher controlDispatcher; private boolean prepared; private boolean rebuffering; private int currentWindowIndex; @@ -125,18 +129,13 @@ import java.util.List; * @param listener A {@link Listener}. * @param player The {@link Player}. * @param mediaItemConverter The {@link MediaItemConverter}. - * @param controlDispatcher A {@link ControlDispatcher}. */ - public PlayerWrapper( - Listener listener, - Player player, - MediaItemConverter mediaItemConverter, - ControlDispatcher controlDispatcher) { + public PlayerWrapper(Listener listener, Player player, MediaItemConverter mediaItemConverter) { this.listener = listener; this.player = player; this.mediaItemConverter = mediaItemConverter; - this.controlDispatcher = controlDispatcher; + controlDispatcher = new DefaultControlDispatcher(); componentListener = new ComponentListener(); player.addListener(componentListener); @Nullable Player.AudioComponent audioComponent = player.getAudioComponent(); @@ -144,7 +143,7 @@ import java.util.List; audioComponent.addAudioListener(componentListener); } - handler = new PlayerHandler(player.getApplicationLooper()); + handler = new Handler(player.getApplicationLooper()); pollBufferRunnable = new PollBufferRunnable(); media2Playlist = new ArrayList<>(); @@ -157,6 +156,10 @@ import java.util.List; updatePlaylist(player.getCurrentTimeline()); } + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + this.controlDispatcher = controlDispatcher; + } + public boolean setMediaItem(androidx.media2.common.MediaItem media2MediaItem) { return setPlaylist(Collections.singletonList(media2MediaItem), /* metadata= */ null); } @@ -436,7 +439,7 @@ import java.util.List; private void handlePlayerStateChanged(@Player.State int state) { if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) { - handler.postOrRun(pollBufferRunnable); + postOrRun(handler, pollBufferRunnable); } else { handler.removeCallbacks(pollBufferRunnable); } 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 index d4aa888a1a..1c6cc151c9 100644 --- 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 @@ -15,6 +15,9 @@ */ package com.google.android.exoplayer2.ext.media2; +import static com.google.android.exoplayer2.util.Util.postOrRun; + +import android.os.Handler; import androidx.annotation.FloatRange; import androidx.annotation.GuardedBy; import androidx.annotation.IntRange; @@ -64,7 +67,7 @@ public final class SessionPlayerConnector extends SessionPlayer { private static final int END_OF_PLAYLIST = -1; private final Object stateLock = new Object(); - private final PlayerHandler taskHandler; + private final Handler taskHandler; private final Executor taskHandlerExecutor; private final PlayerWrapper player; private final PlayerCommandQueue playerCommandQueue; @@ -89,7 +92,7 @@ public final class SessionPlayerConnector extends SessionPlayer { * @param player The player to wrap. */ public SessionPlayerConnector(Player player) { - this(player, new DefaultMediaItemConverter(), new DefaultControlDispatcher()); + this(player, new DefaultMediaItemConverter()); } /** @@ -97,22 +100,28 @@ public final class SessionPlayerConnector extends SessionPlayer { * * @param player The player to wrap. * @param mediaItemConverter The {@link MediaItemConverter}. - * @param controlDispatcher The {@link ControlDispatcher}. */ - public SessionPlayerConnector( - Player player, MediaItemConverter mediaItemConverter, ControlDispatcher controlDispatcher) { + public SessionPlayerConnector(Player player, MediaItemConverter mediaItemConverter) { Assertions.checkNotNull(player); Assertions.checkNotNull(mediaItemConverter); - Assertions.checkNotNull(controlDispatcher); state = PLAYER_STATE_IDLE; - taskHandler = new PlayerHandler(player.getApplicationLooper()); - taskHandlerExecutor = taskHandler::postOrRun; - ExoPlayerWrapperListener playerListener = new ExoPlayerWrapperListener(); - this.player = new PlayerWrapper(playerListener, player, mediaItemConverter, controlDispatcher); + taskHandler = new Handler(player.getApplicationLooper()); + taskHandlerExecutor = (runnable) -> postOrRun(taskHandler, runnable); + + this.player = new PlayerWrapper(new ExoPlayerWrapperListener(), player, mediaItemConverter); playerCommandQueue = new PlayerCommandQueue(this.player, taskHandler); } + /** + * Sets the {@link ControlDispatcher}. + * + * @param controlDispatcher The {@link ControlDispatcher}. + */ + public void setControlDispatcher(ControlDispatcher controlDispatcher) { + player.setControlDispatcher(controlDispatcher); + } + @Override public ListenableFuture play() { return playerCommandQueue.addCommand( @@ -598,7 +607,8 @@ public final class SessionPlayerConnector extends SessionPlayer { private T runPlayerCallableBlocking(Callable callable) { SettableFuture future = SettableFuture.create(); boolean success = - taskHandler.postOrRun( + postOrRun( + taskHandler, () -> { try { future.set(callable.call()); diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 10e8d19063..6d5f167047 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -170,15 +170,16 @@ public final class MimeTypes { } /** - * Returns true if it is known that all samples in a stream of the given MIME type are guaranteed - * to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on every - * sample). + * Returns true if it is known that all samples in a stream of the given MIME type and codec are + * guaranteed to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on + * every sample). * - * @param mimeType A MIME type. - * @return True if it is known that all samples in a stream of the given MIME type are guaranteed - * to be sync samples. False otherwise, including if {@code null} is passed. + * @param mimeType The MIME type of the stream. + * @param codec The RFC 6381 codec string of the stream, or {@code null} if unknown. + * @return Whether it is known that all samples in the stream are guaranteed to be sync samples. */ - public static boolean allSamplesAreSyncSamples(@Nullable String mimeType) { + public static boolean allSamplesAreSyncSamples( + @Nullable String mimeType, @Nullable String codec) { if (mimeType == null) { return false; } @@ -198,6 +199,20 @@ public final class MimeTypes { case AUDIO_E_AC3: case AUDIO_E_AC3_JOC: return true; + case AUDIO_AAC: + if (codec == null) { + return false; + } + @Nullable Mp4aObjectType objectType = getObjectTypeFromMp4aRFC6381CodecString(codec); + if (objectType == null) { + return false; + } + @C.Encoding + int encoding = AacUtil.getEncodingForAudioObjectType(objectType.audioObjectTypeIndication); + // xHE-AAC is an exception in which it's not true that all samples will be sync samples. + // Also return false for ENCODING_INVALID, which indicates we weren't able to parse the + // encoding from the codec string. + return encoding != C.ENCODING_INVALID && encoding != C.ENCODING_AAC_XHE; default: return false; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 7fa26a94f4..f954b60c45 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -493,6 +493,24 @@ public final class Util { return new Handler(looper, callback); } + /** + * Posts the {@link Runnable} if the calling thread differs with the {@link Looper} of the {@link + * Handler}. Otherwise, runs the {@link Runnable} directly. + * + * @param handler The handler to which the {@link Runnable} will be posted. + * @param runnable The runnable to either post or run. + * @return {@code true} if the {@link Runnable} was successfully posted to the {@link Handler} or + * run. {@code false} otherwise. + */ + public static boolean postOrRun(Handler handler, Runnable runnable) { + if (handler.getLooper() == Looper.myLooper()) { + runnable.run(); + return true; + } else { + return handler.post(runnable); + } + } + /** * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the * application's main thread if the current thread doesn't have a {@link Looper}. diff --git a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java index 34ad0a5946..46202a5991 100644 --- a/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java +++ b/library/common/src/test/java/com/google/android/exoplayer2/util/MimeTypesTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.util; +import static android.media.MediaCodecInfo.CodecProfileLevel.AACObjectHE; +import static android.media.MediaCodecInfo.CodecProfileLevel.AACObjectXHE; import static com.google.common.truth.Truth.assertThat; import androidx.annotation.Nullable; @@ -171,4 +173,17 @@ public final class MimeTypesTest { assertThat(objectType.objectTypeIndication).isEqualTo(expectedObjectTypeIndicator); assertThat(objectType.audioObjectTypeIndication).isEqualTo(expectedAudioObjectTypeIndicator); } + + @Test + public void allSamplesAreSyncSamples_forAac_usesCodec() { + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40." + AACObjectHE)) + .isTrue(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40." + AACObjectXHE)) + .isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "mp4a.40.")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, "invalid")).isFalse(); + assertThat(MimeTypes.allSamplesAreSyncSamples(MimeTypes.AUDIO_AAC, /* codec= */ null)) + .isFalse(); + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 490022de93..9d9d9cdc2d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -374,8 +374,6 @@ public interface Player { } /** The device component of a {@link Player}. */ - // Note: It's mostly from the androidx.media.VolumeProviderCompat and - // androidx.media.MediaControllerCompat.PlaybackInfo. interface DeviceComponent { /** Adds a listener to receive device events. */ diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index b2268e1fcc..e992eb588d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -48,62 +48,74 @@ import com.google.android.exoplayer2.util.Util; *

Single media file or on-demand stream

* *

Example timeline for a
- * single file A timeline for a single media file or on-demand stream consists of a single period - * and window. The window spans the whole period, indicating that all parts of the media are - * available for playback. The window's default position is typically at the start of the period - * (indicated by the black dot in the figure above). + * single file"> + * + *

A timeline for a single media file or on-demand stream consists of a single period and window. + * The window spans the whole period, indicating that all parts of the media are available for + * playback. The window's default position is typically at the start of the period (indicated by the + * black dot in the figure above). * *

Playlist of media files or on-demand streams

* *

Example timeline for a
- * playlist of files A timeline for a playlist of media files or on-demand streams consists of - * multiple periods, each with its own window. Each window spans the whole of the corresponding - * period, and typically has a default position at the start of the period. The properties of the - * periods and windows (e.g. their durations and whether the window is seekable) will often only - * become known when the player starts buffering the corresponding file or stream. + * playlist of files"> + * + *

A timeline for a playlist of media files or on-demand streams consists of multiple periods, + * each with its own window. Each window spans the whole of the corresponding period, and typically + * has a default position at the start of the period. The properties of the periods and windows + * (e.g. their durations and whether the window is seekable) will often only become known when the + * player starts buffering the corresponding file or stream. * *

Live stream with limited availability

* *

Example timeline for
- * a live stream with limited availability A timeline for a live stream consists of a period whose - * duration is unknown, since it's continually extending as more content is broadcast. If content - * only remains available for a limited period of time then the window may start at a non-zero - * position, defining the region of content that can still be played. The window will have {@link - * Window#isLive} set to true to indicate it's a live stream and {@link Window#isDynamic} set to - * true as long as we expect changes to the live window. Its default position is typically near to - * the live edge (indicated by the black dot in the figure above). + * a live stream with limited availability"> + * + *

A timeline for a live stream consists of a period whose duration is unknown, since it's + * continually extending as more content is broadcast. If content only remains available for a + * limited period of time then the window may start at a non-zero position, defining the region of + * content that can still be played. The window will have {@link Window#isLive} set to true to + * indicate it's a live stream and {@link Window#isDynamic} set to true as long as we expect changes + * to the live window. Its default position is typically near to the live edge (indicated by the + * black dot in the figure above). * *

Live stream with indefinite availability

* *

Example timeline
- * for a live stream with indefinite availability A timeline for a live stream with indefinite - * availability is similar to the Live stream with limited availability - * case, except that the window starts at the beginning of the period to indicate that all of the - * previously broadcast content can still be played. + * for a live stream with indefinite availability"> + * + *

A timeline for a live stream with indefinite availability is similar to the Live stream with limited availability case, except that the window + * starts at the beginning of the period to indicate that all of the previously broadcast content + * can still be played. * *

Live stream with multiple periods

* *

Example timeline
- * for a live stream with multiple periods This case arises when a live stream is explicitly - * divided into separate periods, for example at content boundaries. This case is similar to the Live stream with limited availability case, except that the window may - * span more than one period. Multiple periods are also possible in the indefinite availability - * case. + * for a live stream with multiple periods"> + * + *

This case arises when a live stream is explicitly divided into separate periods, for example + * at content boundaries. This case is similar to the Live stream with + * limited availability case, except that the window may span more than one period. Multiple + * periods are also possible in the indefinite availability case. * *

On-demand stream followed by live stream

* *

Example timeline for an
- * on-demand stream followed by a live stream This case is the concatenation of the Single media file or on-demand stream and Live - * stream with multiple periods cases. When playback of the on-demand stream ends, playback of - * the live stream will start from its default position near the live edge. + * on-demand stream followed by a live stream"> + * + *

This case is the concatenation of the Single media file or on-demand + * stream and Live stream with multiple periods cases. When playback + * of the on-demand stream ends, playback of the live stream will start from its default position + * near the live edge. * *

On-demand stream with mid-roll ads

* *

Example
- * timeline for an on-demand stream with mid-roll ad groups This case includes mid-roll ad groups, - * which are defined as part of the timeline's single period. The period can be queried for - * information about the ad groups and the ads they contain. + * timeline for an on-demand stream with mid-roll ad groups"> + * + *

This case includes mid-roll ad groups, which are defined as part of the timeline's single + * period. The period can be queried for information about the ad groups and the ads they contain. */ public abstract class Timeline { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java index c32a6fc1a3..0720d9677f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/DrmSessionEventListener.java @@ -15,8 +15,9 @@ */ package com.google.android.exoplayer2.drm; +import static com.google.android.exoplayer2.util.Util.postOrRun; + import android.os.Handler; -import android.os.Looper; import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player; @@ -207,15 +208,6 @@ public interface DrmSessionEventListener { } } - /** Dispatches {@link #onDrmSessionAcquired(int, MediaPeriodId)}. */ - private static void postOrRun(Handler handler, Runnable runnable) { - if (handler.getLooper() == Looper.myLooper()) { - runnable.run(); - } else { - handler.post(runnable); - } - } - private static final class ListenerAndHandler { public Handler handler; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java index 761e101ce0..de3f595976 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -1442,6 +1442,9 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } if (codec == null) { + if (!legacyKeepAvailableCodecInfosWithoutCodec()) { + availableCodecInfos = null; + } maybeInitCodecOrBypass(); return; } @@ -1506,6 +1509,14 @@ public abstract class MediaCodecRenderer extends BaseRenderer { } } + /** + * Returns whether to keep available codec infos when the codec hasn't been initialized, which is + * the behavior before a bug fix. See also [Internal: b/162837741]. + */ + protected boolean legacyKeepAvailableCodecInfosWithoutCodec() { + return false; + } + /** * Called when one of the output formats changes. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java index 8cb619f2a2..df2d10ae53 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -77,7 +77,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; *

A typical usage of DownloadHelper follows these steps: * *

    - *
  1. Build the helper using one of the {@code forXXX} methods. + *
  2. Build the helper using one of the {@code forMediaItem} methods. *
  3. Prepare the helper using {@link #prepare(Callback)} and wait for the callback. *
  4. Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link * #getTrackSelections(int, int)}, and make adjustments using {@link @@ -448,14 +448,7 @@ public final class DownloadHelper { DataSource.Factory dataSourceFactory, @Nullable DrmSessionManager drmSessionManager) { return createMediaSourceInternal( - new MediaItem.Builder() - .setUri(downloadRequest.uri) - .setCustomCacheKey(downloadRequest.customCacheKey) - .setMimeType(downloadRequest.mimeType) - .setStreamKeys(downloadRequest.streamKeys) - .build(), - dataSourceFactory, - drmSessionManager); + downloadRequest.toMediaItem(), dataSourceFactory, drmSessionManager); } private final MediaItem.PlaybackProperties playbackProperties; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java index 31d86349ce..1fa1655445 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/offline/DownloadRequest.java @@ -22,6 +22,7 @@ import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; @@ -220,6 +221,18 @@ public final class DownloadRequest implements Parcelable { newRequest.data); } + /** Returns a {@link MediaItem} for the content defined by the request. */ + public MediaItem toMediaItem() { + return new MediaItem.Builder() + .setMediaId(id) + .setUri(uri) + .setCustomCacheKey(customCacheKey) + .setMimeType(mimeType) + .setStreamKeys(streamKeys) + .setDrmKeySetId(keySetId) + .build(); + } + @Override public String toString() { return mimeType + ":" + id; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java index 31254907fe..7bb6a83add 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -264,7 +264,8 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb for (TrackSelection trackSelection : selections) { if (trackSelection != null) { Format selectedFormat = trackSelection.getSelectedFormat(); - if (!MimeTypes.allSamplesAreSyncSamples(selectedFormat.sampleMimeType)) { + if (!MimeTypes.allSamplesAreSyncSamples( + selectedFormat.sampleMimeType, selectedFormat.codecs)) { return true; } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java index 00ea71efd4..39fd6d53a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSourceEventListener.java @@ -15,8 +15,9 @@ */ package com.google.android.exoplayer2.source; +import static com.google.android.exoplayer2.util.Util.postOrRun; + import android.os.Handler; -import android.os.Looper; import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; @@ -471,14 +472,6 @@ public interface MediaSourceEventListener { return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; } - private static void postOrRun(Handler handler, Runnable runnable) { - if (handler.getLooper() == Looper.myLooper()) { - runnable.run(); - } else { - handler.post(runnable); - } - } - private static final class ListenerAndHandler { public Handler handler; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index 28d25feb03..20d9f44562 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -215,6 +215,22 @@ public class SampleQueue implements TrackOutput { sampleDataQueue.discardUpstreamSampleBytes(discardUpstreamSampleMetadata(discardFromIndex)); } + /** + * Discards samples from the write side of the queue. + * + * @param timeUs Samples will be discarded from the write end of the queue until a sample with a + * timestamp smaller than timeUs is encountered (this sample is not discarded). Must be larger + * than {@link #getLargestReadTimestampUs()}. + */ + public final void discardUpstreamFrom(long timeUs) { + if (length == 0) { + return; + } + checkArgument(timeUs > getLargestReadTimestampUs()); + int retainCount = countUnreadSamplesBefore(timeUs); + discardUpstreamSamples(absoluteFirstIndex + retainCount); + } + // Called by the consuming thread. /** Calls {@link #discardToEnd()} and releases any resources owned by the queue. */ @@ -278,6 +294,16 @@ public class SampleQueue implements TrackOutput { return largestQueuedTimestampUs; } + /** + * Returns the largest sample timestamp that has been read since the last {@link #reset}. + * + * @return The largest sample timestamp that has been read, or {@link Long#MIN_VALUE} if no + * samples have been read. + */ + public final synchronized long getLargestReadTimestampUs() { + return max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); + } + /** * Returns whether the last sample of the stream has knowingly been queued. A return value of * {@code false} means that the last sample had not been queued or that it's unknown whether the @@ -659,7 +685,7 @@ public class SampleQueue implements TrackOutput { upstreamFormat = format; } upstreamAllSamplesAreSyncSamples = - MimeTypes.allSamplesAreSyncSamples(upstreamFormat.sampleMimeType); + MimeTypes.allSamplesAreSyncSamples(upstreamFormat.sampleMimeType, upstreamFormat.codecs); loggedUnexpectedNonSyncSample = false; return true; } @@ -777,20 +803,10 @@ public class SampleQueue implements TrackOutput { if (length == 0) { return timeUs > largestDiscardedTimestampUs; } - long largestReadTimestampUs = - max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); - if (largestReadTimestampUs >= timeUs) { + if (getLargestReadTimestampUs() >= timeUs) { return false; } - int retainCount = length; - int relativeSampleIndex = getRelativeIndex(length - 1); - while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { - retainCount--; - relativeSampleIndex--; - if (relativeSampleIndex == -1) { - relativeSampleIndex = capacity - 1; - } - } + int retainCount = countUnreadSamplesBefore(timeUs); discardUpstreamSampleMetadata(absoluteFirstIndex + retainCount); return true; } @@ -887,6 +903,26 @@ public class SampleQueue implements TrackOutput { return sampleCountToTarget; } + /** + * Counts the number of samples that haven't been read that have a timestamp smaller than {@code + * timeUs}. + * + * @param timeUs The specified time. + * @return The number of unread samples with a timestamp smaller than {@code timeUs}. + */ + private int countUnreadSamplesBefore(long timeUs) { + int count = length; + int relativeSampleIndex = getRelativeIndex(length - 1); + while (count > readPosition && timesUs[relativeSampleIndex] >= timeUs) { + count--; + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + return count; + } + /** * Discards the specified number of samples. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index aa82d41414..a441e81bc4 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -318,12 +318,6 @@ public class EventLogger implements AnalyticsListener { logd(eventTime, "audioInputFormat", Format.toLogString(format)); } - @Override - public void onAudioPositionAdvancing(EventTime eventTime, long playoutStartSystemTimeMs) { - long timeSincePlayoutStartMs = System.currentTimeMillis() - playoutStartSystemTimeMs; - logd(eventTime, "audioPositionAdvancing", "timeSincePlayoutStartMs=" + timeSincePlayoutStartMs); - } - @Override public void onAudioUnderrun( EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 0043cb9e74..66557753f4 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2; import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static com.google.android.exoplayer2.testutil.FakeSampleStream.FakeSampleStreamItem.oneByteSample; +import static com.google.android.exoplayer2.testutil.TestExoPlayer.playUntilStartOfWindow; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilPlaybackState; import static com.google.android.exoplayer2.testutil.TestExoPlayer.runUntilTimelineChanged; import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil; @@ -114,9 +115,11 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatcher; import org.mockito.InOrder; import org.mockito.Mockito; @@ -439,49 +442,41 @@ public final class ExoPlayerTest { public void repeatModeChanges() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 3); FakeRenderer renderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); - ActionSchedule actionSchedule = - new ActionSchedule.Builder(TAG) - .pause() - .waitForTimelineChanged( - timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - .playUntilStartOfWindow(/* windowIndex= */ 1) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 1) - .setRepeatMode(Player.REPEAT_MODE_OFF) - .playUntilStartOfWindow(/* windowIndex= */ 2) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 2) - .setRepeatMode(Player.REPEAT_MODE_ALL) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .setRepeatMode(Player.REPEAT_MODE_ONE) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .playUntilStartOfWindow(/* windowIndex= */ 0) - .setRepeatMode(Player.REPEAT_MODE_OFF) - .play() - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder(context) - .setTimeline(timeline) - .setRenderers(renderer) - .setActionSchedule(actionSchedule) - .build() - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertPlayedPeriodIndices(0, 1, 1, 2, 2, 0, 0, 0, 1, 2); - testRunner.assertPositionDiscontinuityReasonsEqual( - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, - Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, - Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + SimpleExoPlayer player = new TestExoPlayer.Builder(context).setRenderers(renderer).build(); + AnalyticsListener mockAnalyticsListener = mock(AnalyticsListener.class); + player.addAnalyticsListener(mockAnalyticsListener); + + player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.VIDEO_FORMAT)); + player.prepare(); + runUntilTimelineChanged(player); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + playUntilStartOfWindow(player, /* windowIndex= */ 0); + player.setRepeatMode(Player.REPEAT_MODE_OFF); + playUntilStartOfWindow(player, /* windowIndex= */ 1); + playUntilStartOfWindow(player, /* windowIndex= */ 2); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + + ArgumentCaptor eventTimes = + ArgumentCaptor.forClass(AnalyticsListener.EventTime.class); + verify(mockAnalyticsListener, times(10)) + .onMediaItemTransition(eventTimes.capture(), any(), anyInt()); + assertThat( + eventTimes.getAllValues().stream() + .map(eventTime -> eventTime.currentWindowIndex) + .collect(Collectors.toList())) + .containsExactly(0, 1, 1, 2, 2, 0, 0, 0, 1, 2) + .inOrder(); assertThat(renderer.isEnded).isTrue(); } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java index ed34a4eca8..37d31569c3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/mediacodec/AsynchronousMediaCodecBufferEnqueuerTest.java @@ -21,8 +21,8 @@ import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doAnswer; import android.media.MediaCodec; +import android.media.MediaFormat; import android.os.HandlerThread; -import android.os.Looper; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.decoder.CryptoInfo; @@ -31,15 +31,12 @@ import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; import org.junit.After; import org.junit.Before; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import org.robolectric.Shadows; -import org.robolectric.shadows.ShadowLooper; /** Unit tests for {@link AsynchronousMediaCodecBufferEnqueuer}. */ @RunWith(AndroidJUnit4.class) @@ -54,6 +51,8 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { @Before public void setUp() throws IOException { codec = MediaCodec.createByCodecName("h264"); + codec.configure(new MediaFormat(), /* surface= */ null, /* crypto= */ null, /* flags= */ 0); + codec.start(); handlerThread = new TestHandlerThread("TestHandlerThread"); enqueuer = new AsynchronousMediaCodecBufferEnqueuer(codec, handlerThread, mockConditionVariable); @@ -62,7 +61,8 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { @After public void tearDown() { enqueuer.shutdown(); - + codec.stop(); + codec.release(); assertThat(TestHandlerThread.INSTANCES_STARTED.get()).isEqualTo(0); } @@ -98,32 +98,6 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } - @Ignore - @Test - public void queueInputBuffer_multipleTimes_limitsObjectsAllocation() { - enqueuer.start(); - Looper looper = handlerThread.getLooper(); - ShadowLooper shadowLooper = Shadows.shadowOf(looper); - - for (int cycle = 0; cycle < 100; cycle++) { - // This test assumes that the shadow MediaCodec implementation can dequeue at least - // 10 input buffers before queueing them back. - for (int i = 0; i < 10; i++) { - int inputBufferIndex = codec.dequeueInputBuffer(0); - enqueuer.queueInputBuffer( - /* index= */ inputBufferIndex, - /* offset= */ 0, - /* size= */ 0, - /* presentationTimeUs= */ i, - /* flags= */ 0); - } - // Execute all messages, queues input buffers back to MediaCodec. - shadowLooper.idle(); - } - - assertThat(AsynchronousMediaCodecBufferEnqueuer.getInstancePoolSize()).isEqualTo(10); - } - @Test public void queueSecureInputBuffer_withPendingCryptoException_throwsCryptoException() { enqueuer.setPendingRuntimeException( @@ -159,33 +133,6 @@ public class AsynchronousMediaCodecBufferEnqueuerTest { /* flags= */ 0)); } - @Ignore - @Test - public void queueSecureInputBuffer_multipleTimes_limitsObjectsAllocation() { - enqueuer.start(); - Looper looper = handlerThread.getLooper(); - CryptoInfo info = createCryptoInfo(); - ShadowLooper shadowLooper = Shadows.shadowOf(looper); - - for (int cycle = 0; cycle < 100; cycle++) { - // This test assumes that the shadow MediaCodec implementation can dequeue at least - // 10 input buffers before queueing them back. - int inputBufferIndex = codec.dequeueInputBuffer(0); - for (int i = 0; i < 10; i++) { - enqueuer.queueSecureInputBuffer( - /* index= */ inputBufferIndex, - /* offset= */ 0, - /* info= */ info, - /* presentationTimeUs= */ i, - /* flags= */ 0); - } - // Execute all messages, queues input buffers back to MediaCodec. - shadowLooper.idle(); - } - - assertThat(AsynchronousMediaCodecBufferEnqueuer.getInstancePoolSize()).isEqualTo(10); - } - @Test public void flush_withoutStart_works() { enqueuer.flush(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java index 54e2dd902d..241834fab5 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/SampleQueueTest.java @@ -878,6 +878,118 @@ public final class SampleQueueTest { assertReadTestData(FORMAT_1, 1, 7); } + @Test + public void discardUpstreamFrom() { + writeTestData(); + sampleQueue.discardUpstreamFrom(8000); + assertAllocationCount(10); + sampleQueue.discardUpstreamFrom(7000); + assertAllocationCount(9); + sampleQueue.discardUpstreamFrom(6000); + assertAllocationCount(7); + sampleQueue.discardUpstreamFrom(5000); + assertAllocationCount(5); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(3000); + assertAllocationCount(3); + sampleQueue.discardUpstreamFrom(2000); + assertAllocationCount(2); + sampleQueue.discardUpstreamFrom(1000); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromMulti() { + writeTestData(); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromNonSampleTimestamps() { + writeTestData(); + sampleQueue.discardUpstreamFrom(3500); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(500); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(0); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromBeforeRead() { + writeTestData(); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(4); + assertReadTestData(null, 0, 4); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void discardUpstreamFromAfterRead() { + writeTestData(); + assertReadTestData(null, 0, 3); + sampleQueue.discardUpstreamFrom(8000); + assertAllocationCount(10); + sampleQueue.discardToRead(); + assertAllocationCount(7); + sampleQueue.discardUpstreamFrom(7000); + assertAllocationCount(6); + sampleQueue.discardUpstreamFrom(6000); + assertAllocationCount(4); + sampleQueue.discardUpstreamFrom(5000); + assertAllocationCount(2); + sampleQueue.discardUpstreamFrom(4000); + assertAllocationCount(1); + sampleQueue.discardUpstreamFrom(3000); + assertAllocationCount(0); + assertReadFormat(false, FORMAT_2); + assertNoSamplesToRead(FORMAT_2); + } + + @Test + public void largestQueuedTimestampWithDiscardUpstreamFrom() { + writeTestData(); + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 1]); + // Discarding from upstream should reduce the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()) + .isEqualTo(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 2]); + sampleQueue.discardUpstreamFrom(0); + // Discarding everything from upstream without reading should unset the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(MIN_VALUE); + } + + @Test + public void largestQueuedTimestampWithDiscardUpstreamFromDecodeOrder() { + long[] decodeOrderTimestamps = new long[] {0, 3000, 2000, 1000, 4000, 7000, 6000, 5000}; + writeTestData( + DATA, SAMPLE_SIZES, SAMPLE_OFFSETS, decodeOrderTimestamps, SAMPLE_FORMATS, SAMPLE_FLAGS); + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(7000); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 2]); + // Discarding the last two samples should not change the largest timestamp, due to the decode + // ordering of the timestamps. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(7000); + sampleQueue.discardUpstreamFrom(SAMPLE_TIMESTAMPS[SAMPLE_TIMESTAMPS.length - 3]); + // Once a third sample is discarded, the largest timestamp should have changed. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(4000); + sampleQueue.discardUpstreamFrom(0); + // Discarding everything from upstream without reading should unset the largest timestamp. + assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(MIN_VALUE); + } + @Test public void discardUpstream() { writeTestData(); @@ -986,6 +1098,43 @@ public final class SampleQueueTest { assertThat(sampleQueue.getLargestQueuedTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); } + @Test + public void largestReadTimestampWithReadAll() { + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + assertReadTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(LAST_SAMPLE_TIMESTAMP); + } + + @Test + public void largestReadTimestampWithReads() { + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + + assertReadTestData(/* startFormat= */ null, 0, 2); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[1]); + + assertReadTestData(SAMPLE_FORMATS[1], 2, 3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[4]); + } + + @Test + public void largestReadTimestampWithDiscard() { + // Discarding shouldn't change the read timestamp. + writeTestData(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + sampleQueue.discardUpstreamSamples(5); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(MIN_VALUE); + + assertReadTestData(/* startFormat= */ null, 0, 3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + + sampleQueue.discardUpstreamSamples(3); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + sampleQueue.discardToRead(); + assertThat(sampleQueue.getLargestReadTimestampUs()).isEqualTo(SAMPLE_TIMESTAMPS[2]); + } + @Test public void setSampleOffsetBeforeData() { long sampleOffsetUs = 1000; diff --git a/library/dash/README.md b/library/dash/README.md index 1076716684..2ae77c41aa 100644 --- a/library/dash/README.md +++ b/library/dash/README.md @@ -1,8 +1,20 @@ # ExoPlayer DASH library module # -Provides support for Dynamic Adaptive Streaming over HTTP (DASH) content. To -play DASH content, instantiate a `DashMediaSource` and pass it to -`ExoPlayer.prepare`. +Provides support for Dynamic Adaptive Streaming over HTTP (DASH) content. + +Adding a dependency to this module is all that's required to enable playback of +DASH `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in their default +configurations. Internally, `DefaultMediaSourceFactory` will automatically +detect the presence of the module and convert DASH `MediaItem`s into +`DashMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `DashDownloader` instances to download DASH content. + +For advanced playback use cases, applications can build `DashMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`DashDownloader` can be used directly. ## Links ## diff --git a/library/hls/README.md b/library/hls/README.md index 3470c29e3c..b7eecc1ff8 100644 --- a/library/hls/README.md +++ b/library/hls/README.md @@ -1,7 +1,20 @@ # ExoPlayer HLS library module # -Provides support for HTTP Live Streaming (HLS) content. To play HLS content, -instantiate a `HlsMediaSource` and pass it to `ExoPlayer.prepare`. +Provides support for HTTP Live Streaming (HLS) content. + +Adding a dependency to this module is all that's required to enable playback of +HLS `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in their default +configurations. Internally, `DefaultMediaSourceFactory` will automatically +detect the presence of the module and convert HLS `MediaItem`s into +`HlsMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `HlsDownloader` instances to download HLS content. + +For advanced playback use cases, applications can build `HlsMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`HlsDownloader` can be used directly. ## Links ## diff --git a/library/smoothstreaming/README.md b/library/smoothstreaming/README.md index d53471d17c..2fab69c756 100644 --- a/library/smoothstreaming/README.md +++ b/library/smoothstreaming/README.md @@ -1,7 +1,21 @@ # ExoPlayer SmoothStreaming library module # -Provides support for Smooth Streaming content. To play Smooth Streaming content, -instantiate a `SsMediaSource` and pass it to `ExoPlayer.prepare`. +Provides support for SmoothStreaming content. + +Adding a dependency to this module is all that's required to enable playback of +SmoothStreaming `MediaItem`s added to an `ExoPlayer` or `SimpleExoPlayer` in +their default configurations. Internally, `DefaultMediaSourceFactory` will +automatically detect the presence of the module and convert SmoothStreaming +`MediaItem`s into `SsMediaSource` instances for playback. + +Similarly, a `DownloadManager` in its default configuration will use +`DefaultDownloaderFactory`, which will automatically detect the presence of +the module and build `SsDownloader` instances to download SmoothStreaming +content. + +For advanced playback use cases, applications can build `SsMediaSource` +instances and pass them directly to the player. For advanced download use cases, +`SsDownloader` can be used directly. ## Links ## diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java index fe802f9c0e..65a9a5ed8f 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerControlView.java @@ -38,6 +38,7 @@ 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.Player.State; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.RepeatModeUtil; @@ -480,6 +481,7 @@ public class PlayerControlView extends FrameLayout { } vrButton = findViewById(R.id.exo_vr); setShowVrButton(false); + updateButton(false, false, vrButton); Resources resources = context.getResources(); @@ -793,6 +795,7 @@ public class PlayerControlView extends FrameLayout { public void setVrButtonListener(@Nullable OnClickListener onClickListener) { if (vrButton != null) { vrButton.setOnClickListener(onClickListener); + updateButton(getShowVrButton(), onClickListener != null, vrButton); } } @@ -1204,19 +1207,22 @@ public class PlayerControlView extends FrameLayout { } if (event.getAction() == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (keyCode == KeyEvent.KEYCODE_MEDIA_REWIND) { controlDispatcher.dispatchRewind(player); } else if (event.getRepeatCount() == 0) { switch (keyCode) { case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + case KeyEvent.KEYCODE_HEADSETHOOK: + dispatchPlayPause(player); break; case KeyEvent.KEYCODE_MEDIA_PLAY: - controlDispatcher.dispatchSetPlayWhenReady(player, true); + dispatchPlay(player); break; case KeyEvent.KEYCODE_MEDIA_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, false); + dispatchPause(player); break; case KeyEvent.KEYCODE_MEDIA_NEXT: controlDispatcher.dispatchNext(player); @@ -1239,11 +1245,37 @@ public class PlayerControlView extends FrameLayout { && player.getPlayWhenReady(); } + private void dispatchPlayPause(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE || state == Player.STATE_ENDED || !player.getPlayWhenReady()) { + dispatchPlay(player); + } else { + dispatchPause(player); + } + } + + private void dispatchPlay(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE) { + if (playbackPreparer != null) { + playbackPreparer.preparePlayback(); + } + } else if (state == Player.STATE_ENDED) { + seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); + } + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true); + } + + private void dispatchPause(Player player) { + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false); + } + @SuppressLint("InlinedApi") private static boolean isHandledMediaKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT @@ -1349,20 +1381,15 @@ public class PlayerControlView extends FrameLayout { } else if (previousButton == view) { controlDispatcher.dispatchPrevious(player); } else if (fastForwardButton == view) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (rewindButton == view) { controlDispatcher.dispatchRewind(player); } else if (playButton == view) { - if (player.getPlaybackState() == Player.STATE_IDLE) { - if (playbackPreparer != null) { - playbackPreparer.preparePlayback(); - } - } else if (player.getPlaybackState() == Player.STATE_ENDED) { - seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); - } - controlDispatcher.dispatchSetPlayWhenReady(player, true); + dispatchPlay(player); } else if (pauseButton == view) { - controlDispatcher.dispatchSetPlayWhenReady(player, false); + dispatchPause(player); } else if (repeatToggleButton == view) { controlDispatcher.dispatchSetRepeatMode( player, RepeatModeUtil.getNextRepeatMode(player.getRepeatMode(), repeatToggleModes)); diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java index 06a5341499..e23c91cd16 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/PlayerNotificationManager.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.ui; import android.app.Notification; +import android.app.NotificationChannel; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; @@ -877,6 +878,10 @@ public class PlayerNotificationManager { * *

    See {@link NotificationCompat.Builder#setPriority(int)}. * + *

    To set the priority for API levels above 25, you can create your own {@link + * NotificationChannel} with a given importance level and pass the id of the channel to the {@link + * #PlayerNotificationManager(Context, String, int, MediaDescriptionAdapter) constructor}. + * * @param priority The priority which can be one of {@link NotificationCompat#PRIORITY_DEFAULT}, * {@link NotificationCompat#PRIORITY_MAX}, {@link NotificationCompat#PRIORITY_HIGH}, {@link * NotificationCompat#PRIORITY_LOW} or {@link NotificationCompat#PRIORITY_MIN}. If not set diff --git a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java index 07106686ad..86a802323a 100644 --- a/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java +++ b/library/ui/src/main/java/com/google/android/exoplayer2/ui/StyledPlayerControlView.java @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackPreparer; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.State; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.TrackGroup; @@ -396,7 +397,7 @@ public class StyledPlayerControlView extends FrameLayout { private final String fullScreenEnterContentDescription; @Nullable private Player player; - private com.google.android.exoplayer2.ControlDispatcher controlDispatcher; + private ControlDispatcher controlDispatcher; @Nullable private ProgressUpdateListener progressUpdateListener; @Nullable private PlaybackPreparer playbackPreparer; @@ -537,8 +538,7 @@ public class StyledPlayerControlView extends FrameLayout { extraAdGroupTimesMs = new long[0]; extraPlayedAdGroups = new boolean[0]; componentListener = new ComponentListener(); - controlDispatcher = - new com.google.android.exoplayer2.DefaultControlDispatcher(fastForwardMs, rewindMs); + controlDispatcher = new DefaultControlDispatcher(fastForwardMs, rewindMs); updateProgressAction = this::updateProgress; LayoutInflater.from(context).inflate(controllerLayoutId, /* root= */ this); @@ -635,6 +635,7 @@ public class StyledPlayerControlView extends FrameLayout { vrButton = findViewById(R.id.exo_vr); if (vrButton != null) { setShowVrButton(showVrButton); + updateButton(/* enabled= */ false, vrButton); } // Related to Settings List View @@ -839,9 +840,9 @@ public class StyledPlayerControlView extends FrameLayout { } /** - * Sets the {@link com.google.android.exoplayer2.ControlDispatcher}. + * Sets the {@link ControlDispatcher}. * - * @param controlDispatcher The {@link com.google.android.exoplayer2.ControlDispatcher}. + * @param controlDispatcher The {@link ControlDispatcher}. */ public void setControlDispatcher(ControlDispatcher controlDispatcher) { if (this.controlDispatcher != controlDispatcher) { @@ -1638,19 +1639,22 @@ public class StyledPlayerControlView extends FrameLayout { } if (event.getAction() == KeyEvent.ACTION_DOWN) { if (keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (keyCode == KeyEvent.KEYCODE_MEDIA_REWIND) { controlDispatcher.dispatchRewind(player); } else if (event.getRepeatCount() == 0) { switch (keyCode) { case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + case KeyEvent.KEYCODE_HEADSETHOOK: + dispatchPlayPause(player); break; case KeyEvent.KEYCODE_MEDIA_PLAY: - controlDispatcher.dispatchSetPlayWhenReady(player, true); + dispatchPlay(player); break; case KeyEvent.KEYCODE_MEDIA_PAUSE: - controlDispatcher.dispatchSetPlayWhenReady(player, false); + dispatchPause(player); break; case KeyEvent.KEYCODE_MEDIA_NEXT: controlDispatcher.dispatchNext(player); @@ -1673,11 +1677,37 @@ public class StyledPlayerControlView extends FrameLayout { && player.getPlayWhenReady(); } + private void dispatchPlayPause(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE || state == Player.STATE_ENDED || !player.getPlayWhenReady()) { + dispatchPlay(player); + } else { + dispatchPause(player); + } + } + + private void dispatchPlay(Player player) { + @State int state = player.getPlaybackState(); + if (state == Player.STATE_IDLE) { + if (playbackPreparer != null) { + playbackPreparer.preparePlayback(); + } + } else if (state == Player.STATE_ENDED) { + seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); + } + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true); + } + + private void dispatchPause(Player player) { + controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false); + } + @SuppressLint("InlinedApi") private static boolean isHandledMediaKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD || keyCode == KeyEvent.KEYCODE_MEDIA_REWIND || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_HEADSETHOOK || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT @@ -1806,18 +1836,13 @@ public class StyledPlayerControlView extends FrameLayout { } else if (previousButton == view) { controlDispatcher.dispatchPrevious(player); } else if (fastForwardButton == view) { - controlDispatcher.dispatchFastForward(player); + if (player.getPlaybackState() != Player.STATE_ENDED) { + controlDispatcher.dispatchFastForward(player); + } } else if (rewindButton == view) { controlDispatcher.dispatchRewind(player); } else if (playPauseButton == view) { - if (player.getPlaybackState() == Player.STATE_IDLE) { - if (playbackPreparer != null) { - playbackPreparer.preparePlayback(); - } - } else if (player.getPlaybackState() == Player.STATE_ENDED) { - seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET); - } - controlDispatcher.dispatchSetPlayWhenReady(player, !player.getPlayWhenReady()); + dispatchPlayPause(player); } else if (repeatToggleButton == view) { controlDispatcher.dispatchSetRepeatMode( player, RepeatModeUtil.getNextRepeatMode(player.getRepeatMode(), repeatToggleModes)); diff --git a/library/ui/src/main/res/layout/exo_styled_settings_list_item.xml b/library/ui/src/main/res/layout/exo_styled_settings_list_item.xml index 37ba31a244..b7dc40120a 100644 --- a/library/ui/src/main/res/layout/exo_styled_settings_list_item.xml +++ b/library/ui/src/main/res/layout/exo_styled_settings_list_item.xml @@ -35,6 +35,8 @@ android:minHeight="@dimen/exo_settings_height" android:layout_marginLeft="2dp" android:layout_marginRight="2dp" + android:paddingEnd="4dp" + android:paddingRight="4dp" android:gravity="center|start" android:orientation="vertical"> diff --git a/library/ui/src/main/res/layout/exo_styled_sub_settings_list_item.xml b/library/ui/src/main/res/layout/exo_styled_sub_settings_list_item.xml index ba156ac8b9..9d184f3bbe 100644 --- a/library/ui/src/main/res/layout/exo_styled_sub_settings_list_item.xml +++ b/library/ui/src/main/res/layout/exo_styled_sub_settings_list_item.xml @@ -36,8 +36,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:minHeight="@dimen/exo_settings_height" - android:paddingStart="2dp" - android:paddingLeft="2dp" + android:layout_marginEnd="4dp" + android:layout_marginRight="4dp" android:gravity="center|start" android:textColor="@color/exo_white" android:textSize="@dimen/exo_settings_main_text_size"/> diff --git a/playbacktests/build.gradle b/playbacktests/build.gradle index 105427250b..7fc5d637cb 100644 --- a/playbacktests/build.gradle +++ b/playbacktests/build.gradle @@ -13,6 +13,12 @@ // limitations under the License. apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" +android { + defaultConfig { + multiDexEnabled true + } +} + dependencies { androidTestImplementation 'androidx.test:rules:' + androidxTestRulesVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java index 81425c34ea..b657c10d22 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DashWidevineOfflineTest.java @@ -17,11 +17,12 @@ package com.google.android.exoplayer2.playbacktests.gts; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; -import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; import android.media.MediaDrm.MediaDrmStateException; import android.net.Uri; import android.util.Pair; +import androidx.annotation.Nullable; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.ActivityTestRule; import com.google.android.exoplayer2.Format; @@ -121,19 +122,19 @@ public final class DashWidevineOfflineTest { downloadLicense(); releaseLicense(); // keySetId no longer valid. - Throwable error = - assertThrows( - "Playback should fail because the license has been released.", - Throwable.class, - () -> testRunner.run()); - - // Get the root cause - Throwable cause = error.getCause(); - while (cause != null && cause != error) { - error = cause; - cause = error.getCause(); + try { + testRunner.run(); + fail("Playback should fail because the license has been released."); + } catch (RuntimeException expected) { + // Get the root cause + Throwable error = expected; + @Nullable Throwable cause = error.getCause(); + while (cause != null && cause != error) { + error = cause; + cause = error.getCause(); + } + assertThat(error).isInstanceOf(MediaDrmStateException.class); } - assertThat(error).isInstanceOf(MediaDrmStateException.class); } @Test @@ -144,18 +145,19 @@ public final class DashWidevineOfflineTest { downloadLicense(); releaseLicense(); // keySetId no longer valid. - Throwable error = - assertThrows( - "Playback should fail because the license has been released.", - Throwable.class, - () -> testRunner.run()); - // Get the root cause - Throwable cause = error.getCause(); - while (cause != null && cause != error) { - error = cause; - cause = error.getCause(); + try { + testRunner.run(); + fail("Playback should fail because the license has been released."); + } catch (RuntimeException expected) { + // Get the root cause + Throwable error = expected; + @Nullable Throwable cause = error.getCause(); + while (cause != null && cause != error) { + error = cause; + cause = error.getCause(); + } + assertThat(error).isInstanceOf(IllegalArgumentException.class); } - assertThat(error).isInstanceOf(IllegalArgumentException.class); } @Test diff --git a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java index 71270d21c5..c34390bc03 100644 --- a/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java +++ b/playbacktests/src/androidTest/java/com/google/android/exoplayer2/playbacktests/gts/DebugRenderersFactory.java @@ -34,7 +34,6 @@ import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecInfo; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; -import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.nio.ByteBuffer; @@ -85,9 +84,9 @@ import java.util.ArrayList; private final long[] timestampsList; private final ArrayDeque inputFormatChangeTimesUs; + private final boolean shouldMediaFormatChangeTimesBeChecked; private boolean skipToPositionBeforeRenderingFirstFrame; - private boolean shouldMediaFormatChangeTimesBeChecked; private int startIndex; private int queueSize; @@ -114,6 +113,16 @@ import java.util.ArrayList; maxDroppedFrameCountToNotify); timestampsList = new long[ARRAY_SIZE]; inputFormatChangeTimesUs = new ArrayDeque<>(); + + /* + // Output MediaFormat changes are known to occur too early until API 30 (see [internal: + // b/149818050, b/149751672]). + shouldMediaFormatChangeTimesBeChecked = Util.SDK_INT > 30; + */ + + // [Internal ref: b/149751672] Seeking currently causes an unexpected MediaFormat change, so + // this check is disabled until that is deemed fixed. + shouldMediaFormatChangeTimesBeChecked = false; } @Override @@ -135,10 +144,6 @@ import java.util.ArrayList; // frames up to the current playback position [Internal: b/66494991]. skipToPositionBeforeRenderingFirstFrame = getState() == Renderer.STATE_STARTED; super.configureCodec(codecInfo, codecAdapter, format, crypto, operatingRate); - - // Output MediaFormat changes are known to occur too early until API 30 (see [internal: - // b/149818050, b/149751672]). - shouldMediaFormatChangeTimesBeChecked = Util.SDK_INT > 30; } @Override @@ -186,6 +191,8 @@ import java.util.ArrayList; if (mediaFormat != null && !mediaFormat.equals(currentMediaFormat)) { outputMediaFormatChanged = true; currentMediaFormat = mediaFormat; + } else { + inputFormatChangeTimesUs.remove(); } } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java index 5441d25cd9..6feea08a02 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestExoPlayer.java @@ -20,6 +20,7 @@ import static com.google.android.exoplayer2.testutil.TestUtil.runMainLooperUntil import static com.google.common.truth.Truth.assertThat; import android.content.Context; +import android.os.Handler; import android.os.Looper; import androidx.annotation.Nullable; import com.google.android.exoplayer2.DefaultLoadControl; @@ -37,6 +38,7 @@ import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoListener; import java.util.concurrent.TimeoutException; @@ -396,6 +398,7 @@ public class TestExoPlayer { */ public static void runUntilPositionDiscontinuity( Player player, @Player.DiscontinuityReason int expectedReason) throws TimeoutException { + verifyMainTestThread(player); AtomicBoolean receivedCallback = new AtomicBoolean(false); Player.EventListener listener = new Player.EventListener() { @@ -458,6 +461,59 @@ public class TestExoPlayer { runMainLooperUntil(receivedCallback::get); } + /** + * Calls {@link Player#play()}, runs tasks of the main {@link Looper} until the {@code player} + * reaches the specified position and then pauses the {@code player}. + * + * @param player The {@link Player}. + * @param windowIndex The window. + * @param positionMs The position within the window, in milliseconds. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. + */ + public static void playUntilPosition(ExoPlayer player, int windowIndex, long positionMs) + throws TimeoutException { + verifyMainTestThread(player); + Handler testHandler = Util.createHandlerForCurrentOrMainLooper(); + + AtomicBoolean messageHandled = new AtomicBoolean(); + player + .createMessage( + (messageType, payload) -> { + // Block playback thread until pause command has been sent from test thread. + ConditionVariable blockPlaybackThreadCondition = new ConditionVariable(); + testHandler.post( + () -> { + player.pause(); + messageHandled.set(true); + blockPlaybackThreadCondition.open(); + }); + try { + blockPlaybackThreadCondition.block(); + } catch (InterruptedException e) { + // Ignore. + } + }) + .setPosition(windowIndex, positionMs) + .send(); + player.play(); + runMainLooperUntil(messageHandled::get); + } + + /** + * Calls {@link Player#play()}, runs tasks of the main {@link Looper} until the {@code player} + * reaches the specified window and then pauses the {@code player}. + * + * @param player The {@link Player}. + * @param windowIndex The window. + * @throws TimeoutException If the {@link TestUtil#DEFAULT_TIMEOUT_MS default timeout} is + * exceeded. + */ + public static void playUntilStartOfWindow(ExoPlayer player, int windowIndex) + throws TimeoutException { + playUntilPosition(player, windowIndex, /* positionMs= */ 0); + } + /** * Runs tasks of the main {@link Looper} until the player completely handled all previously issued * commands on the internal playback thread.