diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java new file mode 100644 index 0000000000..c233845e0c --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -0,0 +1,199 @@ +/* + * 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; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.android.exoplayer2.util.Util; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +// TODO(internal b/161127201): discard samples written to the sample queue. +/** Retrieves the static metadata of {@link MediaItem MediaItems}. */ +public final class MetadataRetriever { + + private MetadataRetriever() {} + + /** + * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. + * + *

This is equivalent to using {@code + * retrieveMetadata(DefaultMediaSourceFactory.newInstance(context), mediaItem)}. + * + * @param context The {@link Context}. + * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. + * @return A {@link ListenableFuture} of the result. + */ + public static ListenableFuture retrieveMetadata( + Context context, MediaItem mediaItem) { + return retrieveMetadata(DefaultMediaSourceFactory.newInstance(context), mediaItem); + } + + /** + * Retrieves the {@link TrackGroupArray} corresponding to a {@link MediaItem}. + * + *

This method is thread-safe. + * + * @param mediaSourceFactory mediaSourceFactory The {@link MediaSourceFactory} to use to read the + * data. + * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. + * @return A {@link ListenableFuture} of the result. + */ + public static ListenableFuture retrieveMetadata( + MediaSourceFactory mediaSourceFactory, MediaItem mediaItem) { + // Recreate thread and handler every time this method is called so that it can be used + // concurrently. + return new MetadataRetrieverInternal(mediaSourceFactory).retrieveMetadata(mediaItem); + } + + private static final class MetadataRetrieverInternal { + + private static final int MESSAGE_PREPARE_SOURCE = 0; + private static final int MESSAGE_CHECK_FOR_FAILURE = 1; + private static final int MESSAGE_CONTINUE_LOADING = 2; + private static final int MESSAGE_RELEASE = 3; + + private final MediaSourceFactory mediaSourceFactory; + private final HandlerThread mediaSourceThread; + private final Handler mediaSourceHandler; + private final SettableFuture trackGroupsFuture; + + public MetadataRetrieverInternal(MediaSourceFactory mediaSourceFactory) { + this.mediaSourceFactory = mediaSourceFactory; + mediaSourceThread = new HandlerThread("ExoPlayer:MetadataRetriever"); + mediaSourceThread.start(); + mediaSourceHandler = + Util.createHandler(mediaSourceThread.getLooper(), new MediaSourceHandlerCallback()); + trackGroupsFuture = SettableFuture.create(); + } + + public ListenableFuture retrieveMetadata(MediaItem mediaItem) { + mediaSourceHandler.obtainMessage(MESSAGE_PREPARE_SOURCE, mediaItem).sendToTarget(); + return trackGroupsFuture; + } + + private final class MediaSourceHandlerCallback implements Handler.Callback { + + private static final int ERROR_POLL_INTERVAL_MS = 100; + + private final MediaSourceCaller mediaSourceCaller; + + private @MonotonicNonNull MediaSource mediaSource; + private @MonotonicNonNull MediaPeriod mediaPeriod; + + public MediaSourceHandlerCallback() { + mediaSourceCaller = new MediaSourceCaller(); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_PREPARE_SOURCE: + MediaItem mediaItem = (MediaItem) msg.obj; + mediaSource = mediaSourceFactory.createMediaSource(mediaItem); + mediaSource.prepareSource(mediaSourceCaller, /* mediaTransferListener= */ null); + mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE); + return true; + case MESSAGE_CHECK_FOR_FAILURE: + try { + if (mediaPeriod == null) { + checkNotNull(mediaSource).maybeThrowSourceInfoRefreshError(); + } else { + mediaPeriod.maybeThrowPrepareError(); + } + mediaSourceHandler.sendEmptyMessageDelayed( + MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ ERROR_POLL_INTERVAL_MS); + } catch (Exception e) { + trackGroupsFuture.setException(e); + mediaSourceHandler.obtainMessage(MESSAGE_RELEASE).sendToTarget(); + } + return true; + case MESSAGE_CONTINUE_LOADING: + checkNotNull(mediaPeriod).continueLoading(/* positionUs= */ 0); + return true; + case MESSAGE_RELEASE: + if (mediaPeriod != null) { + checkNotNull(mediaSource).releasePeriod(mediaPeriod); + } + checkNotNull(mediaSource).releaseSource(mediaSourceCaller); + mediaSourceHandler.removeCallbacksAndMessages(/* token= */ null); + mediaSourceThread.quit(); + return true; + default: + return false; + } + } + + private final class MediaSourceCaller implements MediaSource.MediaSourceCaller { + + private final MediaPeriodCallback mediaPeriodCallback; + private final Allocator allocator; + + private boolean mediaPeriodCreated; + + public MediaSourceCaller() { + mediaPeriodCallback = new MediaPeriodCallback(); + allocator = + new DefaultAllocator( + /* trimOnReset= */ true, + /* individualAllocationSize= */ C.DEFAULT_BUFFER_SEGMENT_SIZE); + } + + @Override + public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { + if (mediaPeriodCreated) { + // Ignore dynamic updates. + return; + } + mediaPeriodCreated = true; + mediaPeriod = + source.createPeriod( + new MediaSource.MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0)), + allocator, + /* startPositionUs= */ 0); + mediaPeriod.prepare(mediaPeriodCallback, /* positionUs= */ 0); + } + + private final class MediaPeriodCallback implements MediaPeriod.Callback { + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + trackGroupsFuture.set(mediaPeriod.getTrackGroups()); + mediaSourceHandler.obtainMessage(MESSAGE_RELEASE).sendToTarget(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod mediaPeriod) { + mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING).sendToTarget(); + } + } + } + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java new file mode 100644 index 0000000000..09b546e89e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/MetadataRetrieverTest.java @@ -0,0 +1,109 @@ +/* + * 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; + +import static com.google.android.exoplayer2.MetadataRetriever.retrieveMetadata; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.Context; +import android.net.Uri; +import android.os.SystemClock; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.LooperMode; + +/** Tests for {@link MetadataRetriever}. */ +@RunWith(AndroidJUnit4.class) +@LooperMode(LooperMode.Mode.PAUSED) +public class MetadataRetrieverTest { + + @Test + public void retrieveMetadata_singleMediaItem() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + TrackGroupArray trackGroups = waitAndGetTrackGroups(trackGroupsFuture); + + assertThat(trackGroups.length).isEqualTo(2); + // Video group. + assertThat(trackGroups.get(0).length).isEqualTo(1); + assertThat(trackGroups.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.VIDEO_H264); + // Audio group. + assertThat(trackGroups.get(1).length).isEqualTo(1); + assertThat(trackGroups.get(1).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AAC); + } + + @Test + public void retrieveMetadata_multipleMediaItems() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem1 = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp4/sample.mp4")); + MediaItem mediaItem2 = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/mp3/bear-id3.mp3")); + + ListenableFuture trackGroupsFuture1 = retrieveMetadata(context, mediaItem1); + ListenableFuture trackGroupsFuture2 = retrieveMetadata(context, mediaItem2); + TrackGroupArray trackGroups1 = waitAndGetTrackGroups(trackGroupsFuture1); + TrackGroupArray trackGroups2 = waitAndGetTrackGroups(trackGroupsFuture2); + + // First track group. + assertThat(trackGroups1.length).isEqualTo(2); + // First track group - Video group. + assertThat(trackGroups1.get(0).length).isEqualTo(1); + assertThat(trackGroups1.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.VIDEO_H264); + // First track group - Audio group. + assertThat(trackGroups1.get(1).length).isEqualTo(1); + assertThat(trackGroups1.get(1).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AAC); + + // Second track group. + assertThat(trackGroups2.length).isEqualTo(1); + // Second track group - Audio group. + assertThat(trackGroups2.get(0).length).isEqualTo(1); + assertThat(trackGroups2.get(0).getFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_MPEG); + } + + @Test + public void retrieveMetadata_throwsErrorIfCannotLoad() { + Context context = ApplicationProvider.getApplicationContext(); + MediaItem mediaItem = + MediaItem.fromUri(Uri.parse("asset://android_asset/media/does_not_exist")); + + ListenableFuture trackGroupsFuture = retrieveMetadata(context, mediaItem); + + assertThrows(ExecutionException.class, () -> waitAndGetTrackGroups(trackGroupsFuture)); + } + + private static TrackGroupArray waitAndGetTrackGroups( + ListenableFuture trackGroupsFuture) + throws InterruptedException, ExecutionException { + while (!trackGroupsFuture.isDone()) { + // Simulate advancing SystemClock so that delayed messages sent to handlers are received. + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100); + Thread.sleep(/* millis= */ 100); + } + return trackGroupsFuture.get(); + } +} 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 87d82db2f8..ae523b4387 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 @@ -1097,7 +1097,7 @@ public class StyledPlayerControlView extends FrameLayout { return; } if (playPauseButton != null) { - if (player != null && player.getPlayWhenReady()) { + if (shouldShowPauseButton()) { ((ImageView) playPauseButton) .setImageDrawable(resources.getDrawable(R.drawable.exo_styled_controls_pause)); playPauseButton.setContentDescription( @@ -1665,6 +1665,13 @@ public class StyledPlayerControlView extends FrameLayout { return true; } + private boolean shouldShowPauseButton() { + return player != null + && player.getPlaybackState() != Player.STATE_ENDED + && player.getPlaybackState() != Player.STATE_IDLE + && player.getPlayWhenReady(); + } + @SuppressLint("InlinedApi") private static boolean isHandledMediaKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD