Commit 129a64c4 by aquilescanta Committed by Oliver Woodman

Use getItemIds to get the actual size of the Cast media queue

Issue:#4964
PiperOrigin-RevId: 241311763
parent 334da7de
......@@ -3,6 +3,8 @@
### dev-v2 (not yet released) ###
* Update to Mockito 2
* Cast extension: Work around Cast framework returning a limited-size queue
items list ([#4964](https://github.com/google/ExoPlayer/issues/4964)).
* Add new `ExoPlaybackException` types for remote exceptions and out-of-memory
errors.
* DASH:
......
......@@ -574,7 +574,9 @@ public final class CastPlayer extends BasePlayer {
CastTimeline oldTimeline = currentTimeline;
MediaStatus status = getMediaStatus();
currentTimeline =
status != null ? timelineTracker.getCastTimeline(status) : CastTimeline.EMPTY_CAST_TIMELINE;
status != null
? timelineTracker.getCastTimeline(remoteMediaClient)
: CastTimeline.EMPTY_CAST_TIMELINE;
return !oldTimeline.equals(currentTimeline);
}
......
......@@ -16,23 +16,65 @@
package com.google.android.exoplayer2.ext.cast;
import androidx.annotation.Nullable;
import android.util.SparseArray;
import android.util.SparseIntArray;
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.
*/
/* package */ final class CastTimeline extends Timeline {
/** Holds {@link Timeline} related data for a Cast media item. */
public static final class ItemData {
/** Holds no media information. */
public static final ItemData EMPTY = new ItemData();
/** The duration of the item in microseconds, or {@link C#TIME_UNSET} if unknown. */
public final long durationUs;
/**
* The default start position of the item in microseconds, or {@link C#TIME_UNSET} if unknown.
*/
public final long defaultPositionUs;
private ItemData() {
this(/* durationUs= */ C.TIME_UNSET, /* defaultPositionUs */ C.TIME_UNSET);
}
/**
* Creates an instance.
*
* @param durationUs See {@link #durationsUs}.
* @param defaultPositionUs See {@link #defaultPositionUs}.
*/
public ItemData(long durationUs, long defaultPositionUs) {
this.durationUs = durationUs;
this.defaultPositionUs = defaultPositionUs;
}
/** Returns an instance with the given {@link #durationsUs}. */
public ItemData copyWithDurationUs(long durationUs) {
if (durationUs == this.durationUs) {
return this;
}
return new ItemData(durationUs, defaultPositionUs);
}
/** Returns an instance with the given {@link #defaultPositionsUs}. */
public ItemData copyWithDefaultPositionUs(long defaultPositionUs) {
if (defaultPositionUs == this.defaultPositionUs) {
return this;
}
return new ItemData(durationUs, defaultPositionUs);
}
}
/** {@link Timeline} for a cast queue that has no items. */
public static final CastTimeline EMPTY_CAST_TIMELINE =
new CastTimeline(Collections.emptyList(), Collections.emptyMap());
new CastTimeline(new int[0], new SparseArray<>());
private final SparseIntArray idsToIndex;
private final int[] ids;
......@@ -40,28 +82,23 @@ import java.util.Map;
private final long[] defaultPositionsUs;
/**
* @param items A list of cast media queue items to represent.
* @param contentIdToDurationUsMap A map of content id to duration in microseconds.
* Creates a Cast timeline from the given data.
*
* @param itemIds The ids of the items in the timeline.
* @param itemIdToData Maps item ids to {@link ItemData}.
*/
public CastTimeline(List<MediaQueueItem> items, Map<String, Long> contentIdToDurationUsMap) {
int itemCount = items.size();
int index = 0;
public CastTimeline(int[] itemIds, SparseArray<ItemData> itemIdToData) {
int itemCount = itemIds.length;
idsToIndex = new SparseIntArray(itemCount);
ids = new int[itemCount];
ids = Arrays.copyOf(itemIds, itemCount);
durationsUs = new long[itemCount];
defaultPositionsUs = new long[itemCount];
for (MediaQueueItem item : items) {
int itemId = item.getItemId();
ids[index] = itemId;
idsToIndex.put(itemId, index);
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++;
for (int i = 0; i < ids.length; i++) {
int id = ids[i];
idsToIndex.put(id, i);
ItemData data = itemIdToData.get(id, ItemData.EMPTY);
durationsUs[i] = data.durationUs;
defaultPositionsUs[i] = data.defaultPositionUs;
}
}
......
......@@ -15,53 +15,84 @@
*/
package com.google.android.exoplayer2.ext.cast;
import com.google.android.gms.cast.MediaInfo;
import android.util.SparseArray;
import com.google.android.exoplayer2.C;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import java.util.HashMap;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import java.util.HashSet;
import java.util.List;
/**
* Creates {@link CastTimeline}s from cast receiver app media status.
* Creates {@link CastTimeline CastTimelines} from cast receiver app status updates.
*
* <p>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<String, Long> contentIdToDurationUsMap;
private final HashSet<String> scratchContentIdSet;
private final SparseArray<CastTimeline.ItemData> itemIdToData;
public CastTimelineTracker() {
contentIdToDurationUsMap = new HashMap<>();
scratchContentIdSet = new HashSet<>();
itemIdToData = new SparseArray<>();
}
/**
* Returns a {@link CastTimeline} that represent the given {@code status}.
* Returns a {@link CastTimeline} that represents the state of the given {@code
* remoteMediaClient}.
*
* @param status The Cast media status.
* @return A {@link CastTimeline} that represent the given {@code status}.
* <p>Returned timelines may contain values obtained from {@code remoteMediaClient} in previous
* invocations of this method.
*
* @param remoteMediaClient The Cast media client.
* @return A {@link CastTimeline} that represents the given {@code remoteMediaClient} status.
*/
public CastTimeline getCastTimeline(MediaStatus status) {
MediaInfo mediaInfo = status.getMediaInfo();
List<MediaQueueItem> items = status.getQueueItems();
removeUnusedDurationEntries(items);
public CastTimeline getCastTimeline(RemoteMediaClient remoteMediaClient) {
int[] itemIds = remoteMediaClient.getMediaQueue().getItemIds();
if (itemIds.length > 0) {
// Only remove unused items when there is something in the queue to avoid removing all entries
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
removeUnusedItemDataEntries(itemIds);
}
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
MediaStatus mediaStatus = remoteMediaClient.getMediaStatus();
if (mediaStatus == null) {
return CastTimeline.EMPTY_CAST_TIMELINE;
}
if (mediaInfo != null) {
String contentId = mediaInfo.getContentId();
long durationUs = CastUtils.getStreamDurationUs(mediaInfo);
contentIdToDurationUsMap.put(contentId, durationUs);
int currentItemId = mediaStatus.getCurrentItemId();
long durationUs = CastUtils.getStreamDurationUs(mediaStatus.getMediaInfo());
itemIdToData.put(
currentItemId,
itemIdToData
.get(currentItemId, CastTimeline.ItemData.EMPTY)
.copyWithDurationUs(durationUs));
for (MediaQueueItem item : mediaStatus.getQueueItems()) {
int itemId = item.getItemId();
itemIdToData.put(
itemId,
itemIdToData
.get(itemId, CastTimeline.ItemData.EMPTY)
.copyWithDefaultPositionUs((long) (item.getStartTime() * C.MICROS_PER_SECOND)));
}
return new CastTimeline(items, contentIdToDurationUsMap);
return new CastTimeline(itemIds, itemIdToData);
}
private void removeUnusedDurationEntries(List<MediaQueueItem> items) {
scratchContentIdSet.clear();
for (MediaQueueItem item : items) {
scratchContentIdSet.add(item.getMedia().getContentId());
private void removeUnusedItemDataEntries(int[] itemIds) {
HashSet<Integer> scratchItemIds = new HashSet<>(/* initialCapacity= */ itemIds.length * 2);
for (int id : itemIds) {
scratchItemIds.add(id);
}
int index = 0;
while (index < itemIdToData.size()) {
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
itemIdToData.removeAt(index);
} else {
index++;
}
}
contentIdToDurationUsMap.keySet().retainAll(scratchContentIdSet);
}
}
......@@ -31,11 +31,13 @@ import com.google.android.gms.cast.MediaTrack;
* unknown or not applicable.
*
* @param mediaInfo The media info to get the duration from.
* @return The duration in microseconds.
* @return The duration in microseconds, or {@link C#TIME_UNSET} if unknown or not applicable.
*/
public static long getStreamDurationUs(MediaInfo mediaInfo) {
long durationMs =
mediaInfo != null ? mediaInfo.getStreamDuration() : MediaInfo.UNKNOWN_DURATION;
if (mediaInfo == null) {
return C.TIME_UNSET;
}
long durationMs = mediaInfo.getStreamDuration();
return durationMs != MediaInfo.UNKNOWN_DURATION ? C.msToUs(durationMs) : C.TIME_UNSET;
}
......
......@@ -20,9 +20,10 @@ import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.testutil.TimelineAsserts;
import com.google.android.exoplayer2.util.MimeTypes;
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 com.google.android.gms.cast.framework.media.MediaQueue;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import java.util.Collections;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
......@@ -31,7 +32,6 @@ import org.mockito.Mockito;
@RunWith(AndroidJUnit4.class)
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;
......@@ -39,91 +39,89 @@ public class CastTimelineTrackerTest {
/** 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});
public void testGetCastTimelinePersistsDuration() {
CastTimelineTracker tracker = new CastTimelineTracker();
mediaInfo = getMediaInfo("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 = getMediaInfo("contentId3", DURATION_3_MS);
Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo);
RemoteMediaClient remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 2,
/* currentDurationMs= */ DURATION_2_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(status),
C.msToUs(DURATION_1_MS),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.msToUs(DURATION_3_MS));
C.msToUs(DURATION_2_MS),
C.TIME_UNSET,
C.TIME_UNSET,
C.TIME_UNSET);
mediaInfo = getMediaInfo("contentId2", DURATION_2_MS);
Mockito.when(status.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3},
/* currentItemId= */ 3,
/* currentDurationMs= */ DURATION_3_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(status),
C.msToUs(DURATION_1_MS),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
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 = getMediaInfo("contentId5", DURATION_5_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 3},
/* currentItemId= */ 3,
/* currentDurationMs= */ DURATION_3_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
C.TIME_UNSET,
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
tracker.getCastTimeline(remoteMediaClient), C.TIME_UNSET, C.msToUs(DURATION_3_MS));
mediaInfo = getMediaInfo("contentId3", DURATION_3_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 4,
/* currentDurationMs= */ DURATION_4_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
C.TIME_UNSET,
C.msToUs(DURATION_3_MS),
C.msToUs(DURATION_4_MS),
C.TIME_UNSET);
mediaInfo = getMediaInfo("contentId4", DURATION_4_MS);
Mockito.when(newStatus.getMediaInfo()).thenReturn(mediaInfo);
remoteMediaClient =
mockRemoteMediaClient(
/* itemIds= */ new int[] {1, 2, 3, 4, 5},
/* currentItemId= */ 5,
/* currentDurationMs= */ DURATION_5_MS);
TimelineAsserts.assertPeriodDurations(
tracker.getCastTimeline(newStatus),
tracker.getCastTimeline(remoteMediaClient),
C.TIME_UNSET,
C.TIME_UNSET,
C.msToUs(DURATION_3_MS),
C.msToUs(DURATION_4_MS),
C.msToUs(DURATION_1_MS),
C.msToUs(DURATION_5_MS),
C.msToUs(DURATION_3_MS));
C.msToUs(DURATION_5_MS));
}
private static MediaStatus mockMediaStatus(
int[] itemIds, String[] contentIds, long[] durationsMs) {
ArrayList<MediaQueueItem> items = new ArrayList<>();
for (int i = 0; i < contentIds.length; i++) {
MediaInfo mediaInfo = getMediaInfo(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);
}
private static RemoteMediaClient mockRemoteMediaClient(
int[] itemIds, int currentItemId, long currentDurationMs) {
RemoteMediaClient remoteMediaClient = Mockito.mock(RemoteMediaClient.class);
MediaStatus status = Mockito.mock(MediaStatus.class);
Mockito.when(status.getQueueItems()).thenReturn(items);
return status;
Mockito.when(status.getQueueItems()).thenReturn(Collections.emptyList());
Mockito.when(remoteMediaClient.getMediaStatus()).thenReturn(status);
Mockito.when(status.getMediaInfo()).thenReturn(getMediaInfo(currentDurationMs));
Mockito.when(status.getCurrentItemId()).thenReturn(currentItemId);
MediaQueue mediaQueue = mockMediaQueue(itemIds);
Mockito.when(remoteMediaClient.getMediaQueue()).thenReturn(mediaQueue);
return remoteMediaClient;
}
private static MediaQueue mockMediaQueue(int[] itemIds) {
MediaQueue mediaQueue = Mockito.mock(MediaQueue.class);
Mockito.when(mediaQueue.getItemIds()).thenReturn(itemIds);
return mediaQueue;
}
private static MediaInfo getMediaInfo(String contentId, long durationMs) {
return new MediaInfo.Builder(contentId)
private static MediaInfo getMediaInfo(long durationMs) {
return new MediaInfo.Builder(/*contentId= */ "")
.setStreamDuration(durationMs)
.setContentType(MimeTypes.APPLICATION_MP4)
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment