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 8e17644b4c..52278506c5 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 @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.ext.cast; +import static com.google.android.exoplayer2.util.Util.castNonNull; import static java.lang.Math.min; import android.os.Looper; @@ -39,6 +40,7 @@ import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.ListenerSet; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; import com.google.android.gms.cast.CastStatusCodes; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaQueueItem; @@ -129,6 +131,7 @@ public final class CastPlayer extends BasePlayer { private int pendingSeekCount; private int pendingSeekWindowIndex; private long pendingSeekPositionMs; + @Nullable private PositionInfo pendingMediaItemRemovalPosition; /** * Creates a new cast player that uses a {@link DefaultMediaItemConverter}. @@ -460,23 +463,29 @@ public final class CastPlayer extends BasePlayer { if (getCurrentWindowIndex() != windowIndex) { remoteMediaClient.queueJumpToItem((int) currentTimeline.getPeriod(windowIndex, period).uid, positionMs, null).setResultCallback(seekResultCallback); + } else { + remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback); + } + PositionInfo oldPosition = getCurrentPositionInfo(); + pendingSeekCount++; + pendingSeekWindowIndex = windowIndex; + pendingSeekPositionMs = positionMs; + PositionInfo newPosition = getCurrentPositionInfo(); + listeners.queueEvent( + Player.EVENT_POSITION_DISCONTINUITY, + listener -> { + listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK); + listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK); + }); + if (oldPosition.windowIndex != newPosition.windowIndex) { // TODO(internal b/182261884): queue `onMediaItemTransition` event when the media item is // repeated. - MediaItem mediaItem = currentTimeline.getWindow(windowIndex, window).mediaItem; + MediaItem mediaItem = getCurrentTimeline().getWindow(windowIndex, window).mediaItem; listeners.queueEvent( Player.EVENT_MEDIA_ITEM_TRANSITION, listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK)); - } else { - remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback); } - pendingSeekCount++; - pendingSeekWindowIndex = windowIndex; - pendingSeekPositionMs = positionMs; - // TODO(b/181262841): call new onPositionDiscontinuity callback - listeners.queueEvent( - Player.EVENT_POSITION_DISCONTINUITY, - listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)); updateAvailableCommandsAndNotifyIfChanged(); } else if (pendingSeekCount == 0) { listeners.queueEvent(/* eventFlag= */ C.INDEX_UNSET, EventListener::onSeekProcessed); @@ -657,7 +666,12 @@ public final class CastPlayer extends BasePlayer { // There is no session. We leave the state of the player as it is now. return; } - int previousWindowIndex = this.currentWindowIndex; + int oldWindowIndex = this.currentWindowIndex; + @Nullable + Object oldPeriodUid = + !getCurrentTimeline().isEmpty() + ? getCurrentTimeline().getPeriod(oldWindowIndex, period, /* setIds= */ true).uid + : null; boolean wasPlaying = playbackState == Player.STATE_READY && playWhenReady.value; updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null); boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady.value; @@ -667,16 +681,49 @@ public final class CastPlayer extends BasePlayer { } updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null); boolean playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged(); - - int currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline); + Timeline currentTimeline = getCurrentTimeline(); + currentWindowIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline); + @Nullable + Object currentPeriodUid = + !currentTimeline.isEmpty() + ? currentTimeline.getPeriod(currentWindowIndex, period, /* setIds= */ true).uid + : null; if (!playingPeriodChangedByTimelineChange - && previousWindowIndex != currentWindowIndex + && !Util.areEqual(oldPeriodUid, currentPeriodUid) && pendingSeekCount == 0) { - this.currentWindowIndex = currentWindowIndex; - // TODO(b/181262841): call new onPositionDiscontinuity callback + // Report discontinuity and media item auto transition. + currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true); + currentTimeline.getWindow(oldWindowIndex, window); + long windowDurationMs = window.getDurationMs(); + PositionInfo oldPosition = + new PositionInfo( + window.uid, + period.windowIndex, + period.uid, + period.windowIndex, + /* positionMs= */ windowDurationMs, + /* contentPositionMs= */ windowDurationMs, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + currentTimeline.getPeriod(currentWindowIndex, period, /* setIds= */ true); + currentTimeline.getWindow(currentWindowIndex, window); + PositionInfo newPosition = + new PositionInfo( + window.uid, + period.windowIndex, + period.uid, + period.windowIndex, + /* positionMs= */ window.getDefaultPositionMs(), + /* contentPositionMs= */ window.getDefaultPositionMs(), + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); listeners.queueEvent( Player.EVENT_POSITION_DISCONTINUITY, - listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AUTO_TRANSITION)); + listener -> { + listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AUTO_TRANSITION); + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION); + }); listeners.queueEvent( Player.EVENT_MEDIA_ITEM_TRANSITION, listener -> @@ -731,13 +778,14 @@ public final class CastPlayer extends BasePlayer { */ @SuppressWarnings("deprecation") // Calling deprecated listener method. private boolean updateTimelineAndNotifyIfChanged() { - Timeline previousTimeline = currentTimeline; - int previousWindowIndex = currentWindowIndex; + Timeline oldTimeline = currentTimeline; + int oldWindowIndex = currentWindowIndex; boolean playingPeriodChanged = false; if (updateTimeline()) { // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553]. Timeline timeline = currentTimeline; + // Call onTimelineChanged. listeners.queueEvent( Player.EVENT_TIMELINE_CHANGED, listener -> { @@ -746,15 +794,48 @@ public final class CastPlayer extends BasePlayer { listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); }); - updateAvailableCommandsAndNotifyIfChanged(); - - if (currentTimeline.isEmpty() != previousTimeline.isEmpty()) { - // Timeline initially populated or timeline cleared. - playingPeriodChanged = true; - } else if (!currentTimeline.isEmpty()) { - Object previousWindowUid = previousTimeline.getWindow(previousWindowIndex, window).uid; - playingPeriodChanged = currentTimeline.getIndexOfPeriod(previousWindowUid) == C.INDEX_UNSET; + // Call onPositionDiscontinuity if required. + Timeline currentTimeline = getCurrentTimeline(); + boolean playingPeriodRemoved = false; + if (!oldTimeline.isEmpty()) { + Object oldPeriodUid = + castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true).uid); + playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET; } + if (playingPeriodRemoved) { + PositionInfo oldPosition; + if (pendingMediaItemRemovalPosition != null) { + oldPosition = pendingMediaItemRemovalPosition; + pendingMediaItemRemovalPosition = null; + } else { + // If the media item has been removed by another client, we don't know the removal + // position. We use the current position as a fallback. + oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */ true); + oldTimeline.getWindow(period.windowIndex, window); + oldPosition = + new PositionInfo( + window.uid, + period.windowIndex, + period.uid, + period.windowIndex, + getCurrentPosition(), + getContentPosition(), + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + } + PositionInfo newPosition = getCurrentPositionInfo(); + listeners.queueEvent( + Player.EVENT_POSITION_DISCONTINUITY, + listener -> { + listener.onPositionDiscontinuity(DISCONTINUITY_REASON_REMOVE); + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE); + }); + } + + // Call onMediaItemTransition if required. + playingPeriodChanged = + currentTimeline.isEmpty() != oldTimeline.isEmpty() || playingPeriodRemoved; if (playingPeriodChanged) { listeners.queueEvent( Player.EVENT_MEDIA_ITEM_TRANSITION, @@ -762,6 +843,7 @@ public final class CastPlayer extends BasePlayer { listener.onMediaItemTransition( getCurrentMediaItem(), MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); } + updateAvailableCommandsAndNotifyIfChanged(); } return playingPeriodChanged; } @@ -856,6 +938,10 @@ public final class CastPlayer extends BasePlayer { startWindowIndex = getCurrentWindowIndex(); startPositionMs = getCurrentPosition(); } + Timeline currentTimeline = getCurrentTimeline(); + if (!currentTimeline.isEmpty()) { + pendingMediaItemRemovalPosition = getCurrentPositionInfo(); + } return remoteMediaClient.queueLoad( mediaQueueItems, min(startWindowIndex, mediaQueueItems.length - 1), @@ -891,9 +977,41 @@ public final class CastPlayer extends BasePlayer { if (remoteMediaClient == null || getMediaStatus() == null) { return null; } + Timeline timeline = getCurrentTimeline(); + if (!timeline.isEmpty()) { + Object periodUid = + castNonNull(timeline.getPeriod(getCurrentPeriodIndex(), period, /* setIds= */ true).uid); + for (int uid : uids) { + if (periodUid.equals(uid)) { + pendingMediaItemRemovalPosition = getCurrentPositionInfo(); + break; + } + } + } return remoteMediaClient.queueRemoveItems(uids, /* customData= */ null); } + private PositionInfo getCurrentPositionInfo() { + Timeline currentTimeline = getCurrentTimeline(); + @Nullable + Object newPeriodUid = + !currentTimeline.isEmpty() + ? currentTimeline.getPeriod(getCurrentPeriodIndex(), period, /* setIds= */ true).uid + : null; + @Nullable + Object newWindowUid = + newPeriodUid != null ? currentTimeline.getWindow(period.windowIndex, window).uid : null; + return new PositionInfo( + newWindowUid, + getCurrentWindowIndex(), + newPeriodUid, + getCurrentPeriodIndex(), + getCurrentPosition(), + getContentPosition(), + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + } + private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) { if (this.repeatMode.value != repeatMode) { this.repeatMode.value = repeatMode; diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java index 2309f8fd74..cb17f0da85 100644 --- a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java @@ -36,6 +36,8 @@ import static com.google.android.exoplayer2.Player.COMMAND_SET_SHUFFLE_MODE; import static com.google.android.exoplayer2.Player.COMMAND_SET_SPEED_AND_PITCH; import static com.google.android.exoplayer2.Player.COMMAND_SET_VIDEO_SURFACE; import static com.google.android.exoplayer2.Player.COMMAND_SET_VOLUME; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; +import static com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -49,6 +51,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; +import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; @@ -292,6 +295,109 @@ public class CastPlayerTest { assertThat(mediaQueueItems[1].getMedia().getContentId()).isEqualTo(uri2); } + @SuppressWarnings("deprecation") // Verifies deprecated callback being called correctly. + @Test + public void setMediaItems_replaceExistingPlaylist_notifiesMediaItemTransition() { + List firstPlaylist = new ArrayList<>(); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + firstPlaylist.add( + new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + firstPlaylist.add( + new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + ImmutableList secondPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build()); + + castPlayer.setMediaItems( + firstPlaylist, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + updateTimeLine( + firstPlaylist, /* mediaQueueItemIds= */ new int[] {1, 2}, /* currentItemId= */ 2); + // Replacing existing playlist. + castPlayer.setMediaItems( + secondPlaylist, /* startWindowIndex= */ 0, /* startPositionMs= */ 1000L); + updateTimeLine(secondPlaylist, /* mediaQueueItemIds= */ new int[] {3}, /* currentItemId= */ 3); + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener, times(2)) + .onMediaItemTransition( + mediaItemCaptor.capture(), eq(MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + assertThat(mediaItemCaptor.getAllValues().get(1).playbackProperties.tag).isEqualTo(3); + } + + @SuppressWarnings("deprecation") // Verifies deprecated callback being called correctly. + @Test + public void setMediaItems_replaceExistingPlaylist_notifiesPositionDiscontinuity() { + List firstPlaylist = new ArrayList<>(); + String uri1 = "http://www.google.com/video1"; + String uri2 = "http://www.google.com/video2"; + firstPlaylist.add( + new MediaItem.Builder().setUri(uri1).setMimeType(MimeTypes.APPLICATION_MPD).build()); + firstPlaylist.add( + new MediaItem.Builder().setUri(uri2).setMimeType(MimeTypes.APPLICATION_MP4).build()); + ImmutableList secondPlaylist = + ImmutableList.of( + new MediaItem.Builder() + .setUri(Uri.EMPTY) + .setMimeType(MimeTypes.APPLICATION_MPD) + .build()); + + castPlayer.setMediaItems( + firstPlaylist, /* startWindowIndex= */ 1, /* startPositionMs= */ 2000L); + updateTimeLine( + firstPlaylist, + /* mediaQueueItemIds= */ new int[] {1, 2}, + /* currentItemId= */ 2, + /* streamTypes= */ new int[] { + MediaInfo.STREAM_TYPE_BUFFERED, MediaInfo.STREAM_TYPE_BUFFERED + }, + /* durationsMs= */ new long[] {20_000, 20_000}, + /* positionMs= */ 2000L); + // Replacing existing playlist. + castPlayer.setMediaItems( + secondPlaylist, /* startWindowIndex= */ 0, /* startPositionMs= */ 1000L); + updateTimeLine( + secondPlaylist, + /* mediaQueueItemIds= */ new int[] {3}, + /* currentItemId= */ 3, + /* streamTypes= */ new int[] {MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000}, + /* positionMs= */ 1000L); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 2, + /* windowIndex= */ 1, + /* periodUid= */ 2, + /* periodIndex= */ 1, + /* positionMs= */ 2000, + /* contentPositionMs= */ 2000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 3, + /* windowIndex= */ 0, + /* periodUid= */ 3, + /* periodIndex= */ 0, + /* positionMs= */ 1000, + /* contentPositionMs= */ 1000, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(DISCONTINUITY_REASON_REMOVE)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity(eq(oldPosition), eq(newPosition), eq(DISCONTINUITY_REASON_REMOVE)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + } + @Test public void addMediaItems_callsRemoteMediaClient() { MediaItem.Builder builder = new MediaItem.Builder(); @@ -486,12 +592,14 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener) + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) .onMediaItemTransition( mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); assertThat(mediaItemCaptor.getValue().playbackProperties.tag) .isEqualTo(mediaItem.playbackProperties.tag); - verify(mockListener).onMediaItemTransition(any(), anyInt()); } @Test @@ -501,17 +609,74 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); - castPlayer.clearMediaItems(); updateTimeLine( /* mediaItems= */ ImmutableList.of(), /* mediaQueueItemIds= */ new int[0], /* currentItemId= */ C.INDEX_UNSET); - verify(mockListener) + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockListener) .onMediaItemTransition( /* mediaItem= */ null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); - verify(mockListener, times(2)).onMediaItemTransition(any(), anyInt()); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void clearMediaItems_notifiesPositionDiscontinuity() { + int[] mediaQueueItemIds = new int[] {1, 2}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + castPlayer.addMediaItems(mediaItems); + updateTimeLine( + mediaItems, + mediaQueueItemIds, + /* currentItemId= */ 1, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED, MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000L, 30_000L}, + /* positionMs= */ 1234); + castPlayer.clearMediaItems(); + updateTimeLine( + /* mediaItems= */ ImmutableList.of(), + /* mediaQueueItemIds= */ new int[0], + /* currentItemId= */ C.INDEX_UNSET, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000L}, + /* positionMs= */ 0); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 1234, + /* contentPositionMs= */ 1234, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ null, + /* windowIndex= */ 0, + /* periodUid= */ null, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); } @Test @@ -523,19 +688,160 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); - castPlayer.removeMediaItem(/* index= */ 0); + // Update with the new timeline after removal. updateTimeLine( ImmutableList.of(mediaItem2), /* mediaQueueItemIds= */ new int[] {2}, /* currentItemId= */ 2); - verify(mockListener, times(2)) + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener, times(2)) .onMediaItemTransition( mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); - assertThat(mediaItemCaptor.getValue().playbackProperties.tag) + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + assertThat(mediaItemCaptor.getAllValues().get(0).playbackProperties.tag) + .isEqualTo(mediaItem1.playbackProperties.tag); + assertThat(mediaItemCaptor.getAllValues().get(1).playbackProperties.tag) .isEqualTo(mediaItem2.playbackProperties.tag); - verify(mockListener, times(2)).onMediaItemTransition(any(), anyInt()); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void removeCurrentMediaItem_notifiesPositionDiscontinuity() { + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2); + int[] mediaQueueItemIds = new int[] {1, 2}; + + castPlayer.addMediaItems(mediaItems); + updateTimeLine( + mediaItems, + mediaQueueItemIds, + /* currentItemId= */ 1, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED, MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000L, 30_000L}, + /* positionMs= */ 1234); + castPlayer.removeMediaItem(/* index= */ 0); + // Update with the new timeline after removal. + updateTimeLine( + ImmutableList.of(mediaItem2), + /* mediaQueueItemIds= */ new int[] {2}, + /* currentItemId= */ 2, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000L}, + /* positionMs= */ 0); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 1234, + /* contentPositionMs= */ 1234, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 2, + /* windowIndex= */ 0, + /* periodUid= */ 2, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + } + + @Test + public void removeCurrentMediaItem_byRemoteClient_notifiesMediaItemTransition() { + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2); + + castPlayer.addMediaItems(mediaItems); + updateTimeLine(mediaItems, new int[] {1, 2}, /* currentItemId= */ 1); + // Update with the new timeline after removal on the device. + updateTimeLine( + ImmutableList.of(mediaItem2), + /* mediaQueueItemIds= */ new int[] {2}, + /* currentItemId= */ 2); + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener, times(2)) + .onMediaItemTransition( + mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + List capturedMediaItems = mediaItemCaptor.getAllValues(); + assertThat(capturedMediaItems.get(0).playbackProperties.tag) + .isEqualTo(mediaItem1.playbackProperties.tag); + assertThat(capturedMediaItems.get(1).playbackProperties.tag) + .isEqualTo(mediaItem2.playbackProperties.tag); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void removeCurrentMediaItem_byRemoteClient_notifiesPositionDiscontinuity() { + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2); + + castPlayer.addMediaItems(mediaItems); + updateTimeLine( + mediaItems, + new int[] {1, 2}, + /* currentItemId= */ 1, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED, MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {20_000L, 30_000L}, + /* positionMs= */ 1234); + // Update with the new timeline after removal on the device. + updateTimeLine( + ImmutableList.of(mediaItem2), + /* mediaQueueItemIds= */ new int[] {2}, + /* currentItemId= */ 2, + new int[] {MediaInfo.STREAM_TYPE_BUFFERED}, + /* durationsMs= */ new long[] {30_000L}, + /* positionMs= */ 0); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 0, // position at which we receive the timeline change + /* contentPositionMs= */ 0, // position at which we receive the timeline change + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 2, + /* windowIndex= */ 0, + /* periodUid= */ 2, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_REMOVE)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); } @Test @@ -547,14 +853,37 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); - castPlayer.removeMediaItem(/* index= */ 1); updateTimeLine( ImmutableList.of(mediaItem1), /* mediaQueueItemIds= */ new int[] {1}, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void removeNonCurrentMediaItem_doesNotNotifyPositionDiscontinuity() { + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2); + int[] mediaQueueItemIds = new int[] {1, 2}; + + castPlayer.addMediaItems(mediaItems); + updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); + castPlayer.removeMediaItem(/* index= */ 1); + updateTimeLine( + ImmutableList.of(mediaItem1), + /* mediaQueueItemIds= */ new int[] {1}, + /* currentItemId= */ 1); + + verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); } @Test @@ -568,15 +897,63 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); + castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1234); - castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); - verify(mockListener) + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockListener) .onMediaItemTransition( mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); assertThat(mediaItemCaptor.getValue().playbackProperties.tag) .isEqualTo(mediaItem2.playbackProperties.tag); - verify(mockListener, times(2)).onMediaItemTransition(any(), anyInt()); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void seekTo_otherWindow_notifiesPositionDiscontinuity() { + when(mockRemoteMediaClient.queueJumpToItem(anyInt(), anyLong(), eq(null))) + .thenReturn(mockPendingResult); + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2); + int[] mediaQueueItemIds = new int[] {1, 2}; + + castPlayer.addMediaItems(mediaItems); + updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); + castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1234); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 2, + /* windowIndex= */ 1, + /* periodUid= */ 2, + /* periodIndex= */ 1, + /* positionMs= */ 1234, + /* contentPositionMs= */ 1234, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_SEEK)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_SEEK)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); } @Test @@ -588,15 +965,82 @@ public class CastPlayerTest { castPlayer.addMediaItems(mediaItems); updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); - verify(mockListener).onMediaItemTransition(any(), anyInt()); + castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 1234); - castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); - verify(mockListener).onMediaItemTransition(any(), anyInt()); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); } @Test @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. - public void autoTransition_notifiesMediaItemTransitionAndPositionDiscontinuity() { + public void seekTo_sameWindow_notifiesPositionDiscontinuity() { + when(mockRemoteMediaClient.seek(anyLong())).thenReturn(mockPendingResult); + int[] mediaQueueItemIds = new int[] {1, 2}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + castPlayer.addMediaItems(mediaItems); + updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); + castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 1234); + + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 1234, + /* contentPositionMs= */ 1234, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder.verify(mockListener).onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_SEEK)); + inOrder + .verify(mockListener) + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_SEEK)); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); + inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); + } + + @Test + public void autoTransition_notifiesMediaItemTransition() { + int[] mediaQueueItemIds = new int[] {1, 2}; + // When the remote Cast player transitions to an item that wasn't played before, the media state + // delivers the duration for that media item which updates the timeline accordingly. + List mediaItems = createMediaItems(mediaQueueItemIds); + + castPlayer.addMediaItems(mediaItems); + updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1); + updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 2); + + InOrder inOrder = Mockito.inOrder(mockListener); + inOrder + .verify(mockListener) + .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); + inOrder + .verify(mockListener) + .onMediaItemTransition( + mediaItemCaptor.capture(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)); + inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + assertThat(mediaItemCaptor.getValue().playbackProperties.tag).isEqualTo(2); + } + + @Test + @SuppressWarnings("deprecation") // Mocks deprecated method used by the CastPlayer. + public void autoTransition_notifiesPositionDiscontinuity() { int[] mediaQueueItemIds = new int[] {1, 2}; int[] streamTypes = {MediaInfo.STREAM_TYPE_BUFFERED, MediaInfo.STREAM_TYPE_BUFFERED}; long[] durationsFirstMs = {12500, C.TIME_UNSET}; @@ -611,25 +1055,44 @@ public class CastPlayerTest { mediaQueueItemIds, /* currentItemId= */ 1, /* streamTypes= */ streamTypes, - /* durationsMs= */ durationsFirstMs); + /* durationsMs= */ durationsFirstMs, + /* positionMs= */ C.TIME_UNSET); updateTimeLine( mediaItems, mediaQueueItemIds, /* currentItemId= */ 2, /* streamTypes= */ streamTypes, - /* durationsMs= */ durationsSecondMs); + /* durationsMs= */ durationsSecondMs, + /* positionMs= */ C.TIME_UNSET); + Player.PositionInfo oldPosition = + new Player.PositionInfo( + /* windowUid= */ 1, + /* windowIndex= */ 0, + /* periodUid= */ 1, + /* periodIndex= */ 0, + /* positionMs= */ 12500, + /* contentPositionMs= */ 12500, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); + Player.PositionInfo newPosition = + new Player.PositionInfo( + /* windowUid= */ 2, + /* windowIndex= */ 1, + /* periodUid= */ 2, + /* periodIndex= */ 1, + /* positionMs= */ 0, + /* contentPositionMs= */ 0, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET); InOrder inOrder = Mockito.inOrder(mockListener); - inOrder - .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)); inOrder .verify(mockListener) .onPositionDiscontinuity(eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); inOrder .verify(mockListener) - .onMediaItemTransition(any(), eq(Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)); - inOrder.verify(mockListener, never()).onMediaItemTransition(any(), anyInt()); + .onPositionDiscontinuity( + eq(oldPosition), eq(newPosition), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); inOrder.verify(mockListener, never()).onPositionDiscontinuity(anyInt()); inOrder.verify(mockListener, never()).onPositionDiscontinuity(any(), any(), anyInt()); } @@ -674,7 +1137,13 @@ public class CastPlayerTest { long[] durationsMs = new long[] {C.TIME_UNSET}; castPlayer.addMediaItem(mediaItem); - updateTimeLine(mediaItems, mediaQueueItemIds, /* currentItemId= */ 1, streamTypes, durationsMs); + updateTimeLine( + mediaItems, + mediaQueueItemIds, + /* currentItemId= */ 1, + streamTypes, + durationsMs, + /* positionMs= */ C.TIME_UNSET); assertThat(castPlayer.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)).isFalse(); } @@ -1041,7 +1510,13 @@ public class CastPlayerTest { int[] streamTypes = new int[mediaItems.size()]; Arrays.fill(streamTypes, MediaInfo.STREAM_TYPE_BUFFERED); long[] durationsMs = new long[mediaItems.size()]; - updateTimeLine(mediaItems, mediaQueueItemIds, currentItemId, streamTypes, durationsMs); + updateTimeLine( + mediaItems, + mediaQueueItemIds, + currentItemId, + streamTypes, + durationsMs, + /* positionMs= */ C.TIME_UNSET); } private void updateTimeLine( @@ -1049,7 +1524,8 @@ public class CastPlayerTest { int[] mediaQueueItemIds, int currentItemId, int[] streamTypes, - long[] durationsMs) { + long[] durationsMs, + long positionMs) { // Set up mocks to allow the player to update the timeline. List queueItems = new ArrayList<>(); for (int i = 0; i < mediaQueueItemIds.length; i++) { @@ -1074,6 +1550,9 @@ public class CastPlayerTest { when(mockMediaStatus.getMediaInfo()).thenReturn(mediaInfo); } } + if (positionMs != C.TIME_UNSET) { + when(mockRemoteMediaClient.getApproximateStreamPosition()).thenReturn(positionMs); + } when(mockMediaQueue.getItemIds()).thenReturn(mediaQueueItemIds); when(mockMediaStatus.getQueueItems()).thenReturn(queueItems); when(mockMediaStatus.getCurrentItemId())