From ac630b616c6fa5e498ebc17c10cdd9b21cfb2ab0 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Fri, 26 Jan 2018 08:05:52 -0800 Subject: [PATCH] Propagate RemoteMediaClient's current duration to timeline if necessary ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=183390851 --- extensions/cast/build.gradle | 4 + .../exoplayer2/ext/cast/CastPlayer.java | 45 +++--- .../exoplayer2/ext/cast/CastTimeline.java | 59 ++++---- .../ext/cast/CastTimelineTracker.java | 67 +++++++++ .../exoplayer2/ext/cast/CastUtils.java | 15 ++ .../ext/cast/CastTimelineTrackerTest.java | 132 ++++++++++++++++++ .../exoplayer2/testutil/TimelineAsserts.java | 13 ++ 7 files changed, 286 insertions(+), 49 deletions(-) create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java create mode 100644 extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle index 7becb44d1c..11b5b2eeb3 100644 --- a/extensions/cast/build.gradle +++ b/extensions/cast/build.gradle @@ -38,6 +38,10 @@ dependencies { compile 'com.google.android.gms:play-services-cast-framework:' + playServicesLibraryVersion compile project(modulePrefix + 'library-core') compile project(modulePrefix + 'library-ui') + compile project(modulePrefix + 'testutils') + testCompile 'junit:junit:' + junitVersion + testCompile 'org.mockito:mockito-core:' + mockitoVersion + testCompile 'org.robolectric:robolectric:' + robolectricVersion } ext { diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index e545dfd352..6a44c6277e 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -91,6 +91,8 @@ public final class CastPlayer implements Player { private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0]; private final CastContext castContext; + // TODO: Allow custom implementations of CastTimelineTracker. + private final CastTimelineTracker timelineTracker; private final Timeline.Window window; private final Timeline.Period period; @@ -123,6 +125,7 @@ public final class CastPlayer implements Player { */ public CastPlayer(CastContext castContext) { this.castContext = castContext; + timelineTracker = new CastTimelineTracker(); window = new Timeline.Window(); period = new Timeline.Period(); statusListener = new StatusListener(); @@ -487,9 +490,11 @@ public final class CastPlayer implements Player { @Override public long getCurrentPosition() { - return pendingSeekPositionMs != C.TIME_UNSET ? pendingSeekPositionMs - : remoteMediaClient != null ? remoteMediaClient.getApproximateStreamPosition() - : lastReportedPositionMs; + return pendingSeekPositionMs != C.TIME_UNSET + ? pendingSeekPositionMs + : remoteMediaClient != null + ? remoteMediaClient.getApproximateStreamPosition() + : lastReportedPositionMs; } @Override @@ -501,9 +506,9 @@ public final class CastPlayer implements Player { public int getBufferedPercentage() { long position = getBufferedPosition(); long duration = getDuration(); - return position == C.TIME_UNSET || duration == C.TIME_UNSET ? 0 - : duration == 0 ? 100 - : Util.constrainValue((int) ((position * 100) / duration), 0, 100); + return position == C.TIME_UNSET || duration == C.TIME_UNSET + ? 0 + : duration == 0 ? 100 : Util.constrainValue((int) ((position * 100) / duration), 0, 100); } @Override @@ -598,20 +603,11 @@ public final class CastPlayer implements Player { * Updates the current timeline and returns whether it has changed. */ private boolean updateTimeline() { - MediaStatus mediaStatus = getMediaStatus(); - if (mediaStatus == null) { - boolean hasChanged = currentTimeline != CastTimeline.EMPTY_CAST_TIMELINE; - currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE; - return hasChanged; - } - - List items = mediaStatus.getQueueItems(); - if (!currentTimeline.represents(items)) { - currentTimeline = !items.isEmpty() ? new CastTimeline(mediaStatus.getQueueItems()) - : CastTimeline.EMPTY_CAST_TIMELINE; - return true; - } - return false; + CastTimeline oldTimeline = currentTimeline; + MediaStatus status = getMediaStatus(); + currentTimeline = + status != null ? timelineTracker.getCastTimeline(status) : CastTimeline.EMPTY_CAST_TIMELINE; + return !oldTimeline.equals(currentTimeline); } /** @@ -755,10 +751,11 @@ public final class CastPlayer implements Player { } private static int getRendererIndexForTrackType(int trackType) { - return trackType == C.TRACK_TYPE_VIDEO ? RENDERER_INDEX_VIDEO - : trackType == C.TRACK_TYPE_AUDIO ? RENDERER_INDEX_AUDIO - : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT - : C.INDEX_UNSET; + return trackType == C.TRACK_TYPE_VIDEO + ? RENDERER_INDEX_VIDEO + : trackType == C.TRACK_TYPE_AUDIO + ? RENDERER_INDEX_AUDIO + : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT : C.INDEX_UNSET; } private static int getCastRepeatMode(@RepeatMode int repeatMode) { diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java index 39b57148b2..a0be844439 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimeline.java @@ -20,8 +20,10 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaQueueItem; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; /** * A {@link Timeline} for Cast media queues. @@ -29,14 +31,19 @@ import java.util.List; /* package */ final class CastTimeline extends Timeline { public static final CastTimeline EMPTY_CAST_TIMELINE = - new CastTimeline(Collections.emptyList()); + new CastTimeline( + Collections.emptyList(), Collections.emptyMap()); private final SparseIntArray idsToIndex; private final int[] ids; private final long[] durationsUs; private final long[] defaultPositionsUs; - public CastTimeline(List items) { + /** + * @param items A list of cast media queue items to represent. + * @param contentIdToDurationUsMap A map of content id to duration in microseconds. + */ + public CastTimeline(List items, Map contentIdToDurationUsMap) { int itemCount = items.size(); int index = 0; idsToIndex = new SparseIntArray(itemCount); @@ -47,12 +54,19 @@ import java.util.List; int itemId = item.getItemId(); ids[index] = itemId; idsToIndex.put(itemId, index); - durationsUs[index] = getStreamDurationUs(item.getMedia()); + MediaInfo mediaInfo = item.getMedia(); + String contentId = mediaInfo.getContentId(); + durationsUs[index] = + contentIdToDurationUsMap.containsKey(contentId) + ? contentIdToDurationUsMap.get(contentId) + : CastUtils.getStreamDurationUs(mediaInfo); defaultPositionsUs[index] = (long) (item.getStartTime() * C.MICROS_PER_SECOND); index++; } } + // Timeline implementation. + @Override public int getWindowCount() { return ids.length; @@ -83,32 +97,27 @@ import java.util.List; return uid instanceof Integer ? idsToIndex.get((int) uid, C.INDEX_UNSET) : C.INDEX_UNSET; } - /** - * Returns whether the timeline represents a given {@code MediaQueueItem} list. - * - * @param items The {@code MediaQueueItem} list. - * @return Whether the timeline represents {@code items}. - */ - /* package */ boolean represents(List items) { - if (ids.length != items.size()) { + // equals and hashCode implementations. + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (!(other instanceof CastTimeline)) { return false; } - int index = 0; - for (MediaQueueItem item : items) { - if (ids[index] != item.getItemId() - || durationsUs[index] != getStreamDurationUs(item.getMedia()) - || defaultPositionsUs[index] != (long) (item.getStartTime() * C.MICROS_PER_SECOND)) { - return false; - } - index++; - } - return true; + CastTimeline that = (CastTimeline) other; + return Arrays.equals(ids, that.ids) + && Arrays.equals(durationsUs, that.durationsUs) + && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs); } - private static long getStreamDurationUs(MediaInfo mediaInfo) { - long durationMs = mediaInfo != null ? mediaInfo.getStreamDuration() - : MediaInfo.UNKNOWN_DURATION; - return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET; + @Override + public int hashCode() { + int result = Arrays.hashCode(ids); + result = 31 * result + Arrays.hashCode(durationsUs); + result = 31 * result + Arrays.hashCode(defaultPositionsUs); + return result; } } diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java new file mode 100644 index 0000000000..412bfb476d --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastTimelineTracker.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2018 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.cast; + +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * Creates {@link CastTimeline}s from cast receiver app media status. + * + *

This class keeps track of the duration reported by the current item to fill any missing + * durations in the media queue items [See internal: b/65152553]. + */ +/* package */ final class CastTimelineTracker { + + private final HashMap contentIdToDurationUsMap; + private final HashSet scratchContentIdSet; + + public CastTimelineTracker() { + contentIdToDurationUsMap = new HashMap<>(); + scratchContentIdSet = new HashSet<>(); + } + + /** + * Returns a {@link CastTimeline} that represent the given {@code status}. + * + * @param status The Cast media status. + * @return A {@link CastTimeline} that represent the given {@code status}. + */ + public CastTimeline getCastTimeline(MediaStatus status) { + MediaInfo mediaInfo = status.getMediaInfo(); + List items = status.getQueueItems(); + removeUnusedDurationEntries(items); + + if (mediaInfo != null) { + String contentId = mediaInfo.getContentId(); + long durationUs = CastUtils.getStreamDurationUs(mediaInfo); + contentIdToDurationUsMap.put(contentId, durationUs); + } + return new CastTimeline(items, contentIdToDurationUsMap); + } + + private void removeUnusedDurationEntries(List items) { + scratchContentIdSet.clear(); + for (MediaQueueItem item : items) { + scratchContentIdSet.add(item.getMedia().getContentId()); + } + contentIdToDurationUsMap.keySet().retainAll(scratchContentIdSet); + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java index de60437444..6ace54fea5 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java @@ -15,8 +15,10 @@ */ package com.google.android.exoplayer2.ext.cast; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaTrack; import java.util.Collections; import java.util.HashMap; @@ -29,6 +31,19 @@ import java.util.Map; private static final Map CAST_STATUS_CODE_TO_STRING; + /** + * Returns the duration in microseconds advertised by a media info, or {@link C#TIME_UNSET} if + * unknown or not applicable. + * + * @param mediaInfo The media info to get the duration from. + * @return The duration in microseconds. + */ + public static long getStreamDurationUs(MediaInfo mediaInfo) { + long durationMs = + mediaInfo != null ? mediaInfo.getStreamDuration() : MediaInfo.UNKNOWN_DURATION; + return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET; + } + /** * Returns a descriptive log string for the given {@code statusCode}, or "Unknown." if not one of * {@link CastStatusCodes}. diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java new file mode 100644 index 0000000000..bf4b20e156 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastTimelineTrackerTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2018 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.cast; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.testutil.TimelineAsserts; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaQueueItem; +import com.google.android.gms.cast.MediaStatus; +import java.util.ArrayList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Tests for {@link CastTimelineTracker}. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Config.TARGET_SDK, manifest = Config.NONE) +public class CastTimelineTrackerTest { + + private static final long DURATION_1_MS = 1000; + private static final long DURATION_2_MS = 2000; + private static final long DURATION_3_MS = 3000; + private static final long DURATION_4_MS = 4000; + private static final long DURATION_5_MS = 5000; + + /** Tests that duration of the current media info is correctly propagated to the timeline. */ + @Test + public void testGetCastTimeline() { + MediaInfo mediaInfo; + MediaStatus status = + mockMediaStatus( + new int[] {1, 2, 3}, + new String[] {"contentId1", "contentId2", "contentId3"}, + new long[] {DURATION_1_MS, MediaInfo.UNKNOWN_DURATION, MediaInfo.UNKNOWN_DURATION}); + + CastTimelineTracker tracker = new CastTimelineTracker(); + mediaInfo = mockMediaInfo("contentId1", DURATION_1_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), C.msToUs(DURATION_1_MS), C.TIME_UNSET, C.TIME_UNSET); + + mediaInfo = mockMediaInfo("contentId3", DURATION_3_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), + C.msToUs(DURATION_1_MS), + C.TIME_UNSET, + C.msToUs(DURATION_3_MS)); + + mediaInfo = mockMediaInfo("contentId2", DURATION_2_MS); + Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(status), + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_2_MS), + C.msToUs(DURATION_3_MS)); + + MediaStatus newStatus = + mockMediaStatus( + new int[] {4, 1, 5, 3}, + new String[] {"contentId4", "contentId1", "contentId5", "contentId3"}, + new long[] { + MediaInfo.UNKNOWN_DURATION, + MediaInfo.UNKNOWN_DURATION, + DURATION_5_MS, + MediaInfo.UNKNOWN_DURATION + }); + mediaInfo = mockMediaInfo("contentId5", DURATION_5_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.TIME_UNSET, + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + + mediaInfo = mockMediaInfo("contentId3", DURATION_3_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.TIME_UNSET, + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + + mediaInfo = mockMediaInfo("contentId4", DURATION_4_MS); + Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo); + TimelineAsserts.assertPeriodDurations( + tracker.getCastTimeline(newStatus), + C.msToUs(DURATION_4_MS), + C.msToUs(DURATION_1_MS), + C.msToUs(DURATION_5_MS), + C.msToUs(DURATION_3_MS)); + } + + private static MediaStatus mockMediaStatus( + int[] itemIds, String[] contentIds, long[] durationsMs) { + ArrayList items = new ArrayList<>(); + for (int i = 0; i < contentIds.length; i++) { + MediaInfo mediaInfo = mockMediaInfo(contentIds[i], durationsMs[i]); + MediaQueueItem item = Mockito.mock(MediaQueueItem.class); + Mockito.when(item.getMedia()).thenReturn(mediaInfo); + Mockito.when(item.getItemId()).thenReturn(itemIds[i]); + items.add(item); + } + MediaStatus status = Mockito.mock(MediaStatus.class); + Mockito.when(status.getQueueItems()).thenReturn(items); + return status; + } + + private static MediaInfo mockMediaInfo(String contentId, long durationMs) { + MediaInfo mediaInfo = Mockito.mock(MediaInfo.class); + Mockito.when(mediaInfo.getContentId()).thenReturn(contentId); + Mockito.when(mediaInfo.getStreamDuration()).thenReturn(durationMs); + return mediaInfo; + } +} diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java index 5cdc1d7257..42136bfe4d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TimelineAsserts.java @@ -99,6 +99,19 @@ public final class TimelineAsserts { } } + /** + * Asserts that the durations of the periods in the {@link Timeline} and the durations in the + * given sequence are equal. + */ + public static void assertPeriodDurations(Timeline timeline, long... durationsUs) { + int periodCount = timeline.getPeriodCount(); + assertThat(periodCount).isEqualTo(durationsUs.length); + Period period = new Period(); + for (int i = 0; i < periodCount; i++) { + assertThat(timeline.getPeriod(i, period).durationUs).isEqualTo(durationsUs[i]); + } + } + /** * Asserts that period counts for each window are set correctly. Also asserts that * {@link Window#firstPeriodIndex} and {@link Window#lastPeriodIndex} are set correctly, and it