From 607cb829cf801e1099fac880e6ff65543678213a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 12 Feb 2024 11:44:44 +0100 Subject: [PATCH] Improve current media item tracker --- .../test/utils/AnalyticsListenerCommander.kt | 493 -------------- .../player/source/PillarboxMediaSource.kt | 14 +- .../player/tracker/CurrentMediaItemTracker.kt | 202 +++--- .../tracker/CurrentMediaItemTrackerTest.kt | 334 --------- .../player/tracker/FakeMediaItemSource.kt | 44 ++ .../player/tracker/FakeMediaItemTracker.kt | 33 + .../player/tracker/MediaItemTrackerTest.kt | 641 ++++++++++++++++++ 7 files changed, 824 insertions(+), 937 deletions(-) delete mode 100644 pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/AnalyticsListenerCommander.kt delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt diff --git a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/AnalyticsListenerCommander.kt b/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/AnalyticsListenerCommander.kt deleted file mode 100644 index aaa6a6baf..000000000 --- a/pillarbox-player-testutils/src/main/java/ch/srgssr/pillarbox/player/test/utils/AnalyticsListenerCommander.kt +++ /dev/null @@ -1,493 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.test.utils - -import androidx.media3.common.AudioAttributes -import androidx.media3.common.DeviceInfo -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Metadata -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.Player.PositionInfo -import androidx.media3.common.Timeline -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import androidx.media3.common.VideoSize -import androidx.media3.common.text.CueGroup -import androidx.media3.exoplayer.DecoderCounters -import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import java.io.IOException - -/** - * Player listener that intercept AnalyticsListener and allow to simulate listener calls. - */ -open class AnalyticsListenerCommander(exoplayer: ExoPlayer) : - PlayerListenerCommander(exoplayer), - ExoPlayer by exoplayer, - AnalyticsListener { - private val listeners = mutableListOf() - - /** - * Has analytics listener - */ - val hasAnalyticsListener: Boolean - get() = listeners.isNotEmpty() - - override fun addAnalyticsListener(listener: AnalyticsListener) { - listeners.add(listener) - } - - override fun removeAnalyticsListener(listener: AnalyticsListener) { - listeners.remove(listener) - } - - private fun notifyAll(run: (player: AnalyticsListener) -> Unit) { - val list = listeners.toList() - for (listener in list) { - run(listener) - } - } - - /** - * Simulate initial item start - * - * @param item1 - */ - fun simulateItemStart(item1: MediaItem) { - val eventTime = createEventTime(item1) - onTimelineChanged(eventTime, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) - onMediaItemTransition(eventTime, item1, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) - item1.localConfiguration?.let { - simulateItemLoaded(item1) - } - } - - fun simulateItemLoaded(item: MediaItem) { - val eventTime = createEventTime(item) - onPlaybackStateChanged(eventTime, Player.STATE_BUFFERING) - onTimelineChanged(eventTime, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) - onPlaybackStateChanged(eventTime, Player.STATE_READY) - } - - fun simulateItemEnd(item: MediaItem) { - val eventTime = createEventTime(item) - onPlaybackStateChanged(eventTime, Player.STATE_ENDED) - } - - fun simulatedReady(item: MediaItem) { - val eventTime = createEventTime(item) - onPlaybackStateChanged(eventTime, Player.STATE_READY) - } - - fun simulateRelease(item: MediaItem) { - val eventTime = createEventTime(item) - onPlaybackStateChanged(eventTime, Player.STATE_IDLE) - onPlayerReleased(eventTime) - } - - fun simulateItemRemoved(item1: MediaItem, item2: MediaItem? = null) { - val oldPosition = createPositionInfo(item1, 0) - val newPosition = createPositionInfo(item2, 1) - val eventTime = createEventTime(item1, 1) - onPositionDiscontinuity(eventTime, oldPosition, newPosition, Player.DISCONTINUITY_REASON_REMOVE) - onMediaItemTransition(eventTime, item2, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) - } - - fun simulateItemTransitionSeek(item1: MediaItem, item2: MediaItem) { - val oldPosition = createPositionInfo(item1, 0) - val newPosition = createPositionInfo(item2, 1) - val eventTime = createEventTime(item2, 1) - onPositionDiscontinuity(eventTime, oldPosition, newPosition, Player.DISCONTINUITY_REASON_SEEK) - onMediaItemTransition(eventTime, item2, Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) - } - - fun simulateItemTransitionAuto(item1: MediaItem, item2: MediaItem) { - val oldPosition = createPositionInfo(item1, 0) - val newPosition = createPositionInfo(item2, 1) - val eventTime = createEventTime(item2, 1) - onPositionDiscontinuity(eventTime, oldPosition, newPosition, Player.DISCONTINUITY_REASON_AUTO_TRANSITION) - onMediaItemTransition(eventTime, item2, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) - } - - fun simulateItemTransitionRepeat(item1: MediaItem) { - val eventTime = createEventTime(item1, 1) - val oldPosition = createPositionInfo(item1, 0) - val newPosition = createPositionInfo(item1, 0) - onPositionDiscontinuity(eventTime, oldPosition, newPosition, Player.DISCONTINUITY_REASON_AUTO_TRANSITION) - onMediaItemTransition(eventTime, item1, Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) - } - - override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) { - notifyAll { it.onPlaybackStateChanged(eventTime, state) } - } - - override fun onPlayWhenReadyChanged(eventTime: AnalyticsListener.EventTime, playWhenReady: Boolean, reason: Int) { - notifyAll { it.onPlayWhenReadyChanged(eventTime, playWhenReady, reason) } - } - - override fun onPlaybackSuppressionReasonChanged( - eventTime: AnalyticsListener.EventTime, - playbackSuppressionReason: Int - ) { - notifyAll { it.onPlaybackSuppressionReasonChanged(eventTime, playbackSuppressionReason) } - } - - override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { - notifyAll { it.onIsPlayingChanged(eventTime, isPlaying) } - } - - override fun onTimelineChanged(eventTime: AnalyticsListener.EventTime, reason: Int) { - notifyAll { it.onTimelineChanged(eventTime, reason) } - } - - override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - notifyAll { it.onMediaItemTransition(eventTime, mediaItem, reason) } - } - - override fun onPositionDiscontinuity( - eventTime: AnalyticsListener.EventTime, - oldPosition: PositionInfo, - newPosition: PositionInfo, - reason: Int - ) { - notifyAll { it.onPositionDiscontinuity(eventTime, oldPosition, newPosition, reason) } - } - - override fun onPlaybackParametersChanged( - eventTime: AnalyticsListener.EventTime, - playbackParameters: PlaybackParameters - ) { - notifyAll { it.onPlaybackParametersChanged(eventTime, playbackParameters) } - } - - override fun onSeekBackIncrementChanged(eventTime: AnalyticsListener.EventTime, seekBackIncrementMs: Long) { - notifyAll { it.onSeekBackIncrementChanged(eventTime, seekBackIncrementMs) } - } - - override fun onSeekForwardIncrementChanged(eventTime: AnalyticsListener.EventTime, seekForwardIncrementMs: Long) { - notifyAll { it.onSeekForwardIncrementChanged(eventTime, seekForwardIncrementMs) } - } - - override fun onMaxSeekToPreviousPositionChanged( - eventTime: AnalyticsListener.EventTime, - maxSeekToPreviousPositionMs: Long - ) { - notifyAll { it.onMaxSeekToPreviousPositionChanged(eventTime, maxSeekToPreviousPositionMs) } - } - - override fun onRepeatModeChanged(eventTime: AnalyticsListener.EventTime, repeatMode: Int) { - notifyAll { it.onRepeatModeChanged(eventTime, repeatMode) } - } - - override fun onShuffleModeChanged(eventTime: AnalyticsListener.EventTime, shuffleModeEnabled: Boolean) { - notifyAll { it.onShuffleModeChanged(eventTime, shuffleModeEnabled) } - } - - override fun onIsLoadingChanged(eventTime: AnalyticsListener.EventTime, isLoading: Boolean) { - notifyAll { it.onIsLoadingChanged(eventTime, isLoading) } - } - - override fun onAvailableCommandsChanged(eventTime: AnalyticsListener.EventTime, availableCommands: Player.Commands) { - notifyAll { it.onAvailableCommandsChanged(eventTime, availableCommands) } - } - - override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { - notifyAll { - it.onPlayerError(eventTime, error) - } - } - - override fun onPlayerErrorChanged(eventTime: AnalyticsListener.EventTime, error: PlaybackException?) { - notifyAll { it.onPlayerErrorChanged(eventTime, error) } - } - - override fun onTracksChanged(eventTime: AnalyticsListener.EventTime, tracks: Tracks) { - notifyAll { it.onTracksChanged(eventTime, tracks) } - } - - override fun onTrackSelectionParametersChanged( - eventTime: AnalyticsListener.EventTime, - trackSelectionParameters: TrackSelectionParameters - ) { - notifyAll { it.onTrackSelectionParametersChanged(eventTime, trackSelectionParameters) } - } - - override fun onMediaMetadataChanged(eventTime: AnalyticsListener.EventTime, mediaMetadata: MediaMetadata) { - notifyAll { it.onMediaMetadataChanged(eventTime, mediaMetadata) } - } - - override fun onPlaylistMetadataChanged(eventTime: AnalyticsListener.EventTime, playlistMetadata: MediaMetadata) { - notifyAll { it.onPlaylistMetadataChanged(eventTime, playlistMetadata) } - } - - override fun onLoadStarted( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData - ) { - notifyAll { it.onLoadStarted(eventTime, loadEventInfo, mediaLoadData) } - } - - override fun onLoadCompleted( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData - ) { - notifyAll { it.onLoadCompleted(eventTime, loadEventInfo, mediaLoadData) } - } - - override fun onLoadCanceled( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData - ) { - notifyAll { it.onLoadCanceled(eventTime, loadEventInfo, mediaLoadData) } - } - - override fun onLoadError( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - error: IOException, - wasCanceled: Boolean - ) { - notifyAll { it.onLoadError(eventTime, loadEventInfo, mediaLoadData, error, wasCanceled) } - } - - override fun onDownstreamFormatChanged(eventTime: AnalyticsListener.EventTime, mediaLoadData: MediaLoadData) { - notifyAll { it.onDownstreamFormatChanged(eventTime, mediaLoadData) } - } - - override fun onUpstreamDiscarded(eventTime: AnalyticsListener.EventTime, mediaLoadData: MediaLoadData) { - notifyAll { it.onUpstreamDiscarded(eventTime, mediaLoadData) } - } - - override fun onBandwidthEstimate( - eventTime: AnalyticsListener.EventTime, - totalLoadTimeMs: Int, - totalBytesLoaded: Long, - bitrateEstimate: Long - ) { - notifyAll { it.onBandwidthEstimate(eventTime, totalLoadTimeMs, totalBytesLoaded, bitrateEstimate) } - } - - override fun onMetadata(eventTime: AnalyticsListener.EventTime, metadata: Metadata) { - notifyAll { it.onMetadata(eventTime, metadata) } - } - - override fun onCues(eventTime: AnalyticsListener.EventTime, cueGroup: CueGroup) { - notifyAll { it.onCues(eventTime, cueGroup) } - } - - override fun onAudioEnabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - notifyAll { it.onAudioEnabled(eventTime, decoderCounters) } - } - - override fun onAudioDecoderInitialized( - eventTime: AnalyticsListener.EventTime, - decoderName: String, - initializedTimestampMs: Long, - initializationDurationMs: Long - ) { - notifyAll { it.onAudioDecoderInitialized(eventTime, decoderName, initializedTimestampMs, initializationDurationMs) } - } - - override fun onAudioInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - notifyAll { it.onAudioInputFormatChanged(eventTime, format, decoderReuseEvaluation) } - } - - override fun onAudioPositionAdvancing(eventTime: AnalyticsListener.EventTime, playoutStartSystemTimeMs: Long) { - notifyAll { it.onAudioPositionAdvancing(eventTime, playoutStartSystemTimeMs) } - } - - override fun onAudioUnderrun( - eventTime: AnalyticsListener.EventTime, - bufferSize: Int, - bufferSizeMs: Long, - elapsedSinceLastFeedMs: Long - ) { - notifyAll { it.onAudioUnderrun(eventTime, bufferSize, bufferSizeMs, elapsedSinceLastFeedMs) } - } - - override fun onAudioDecoderReleased(eventTime: AnalyticsListener.EventTime, decoderName: String) { - notifyAll { it.onAudioDecoderReleased(eventTime, decoderName) } - } - - override fun onAudioDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - notifyAll { it.onAudioDisabled(eventTime, decoderCounters) } - } - - override fun onAudioSessionIdChanged(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) { - notifyAll { it.onAudioSessionIdChanged(eventTime, audioSessionId) } - } - - override fun onAudioAttributesChanged(eventTime: AnalyticsListener.EventTime, audioAttributes: AudioAttributes) { - notifyAll { it.onAudioAttributesChanged(eventTime, audioAttributes) } - } - - override fun onSkipSilenceEnabledChanged(eventTime: AnalyticsListener.EventTime, skipSilenceEnabled: Boolean) { - notifyAll { it.onSkipSilenceEnabledChanged(eventTime, skipSilenceEnabled) } - } - - override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) { - notifyAll { it.onAudioSinkError(eventTime, audioSinkError) } - } - - override fun onAudioCodecError(eventTime: AnalyticsListener.EventTime, audioCodecError: Exception) { - notifyAll { it.onAudioCodecError(eventTime, audioCodecError) } - } - - override fun onVolumeChanged(eventTime: AnalyticsListener.EventTime, volume: Float) { - notifyAll { it.onVolumeChanged(eventTime, volume) } - } - - override fun onDeviceInfoChanged(eventTime: AnalyticsListener.EventTime, deviceInfo: DeviceInfo) { - notifyAll { it.onDeviceInfoChanged(eventTime, deviceInfo) } - } - - override fun onDeviceVolumeChanged(eventTime: AnalyticsListener.EventTime, volume: Int, muted: Boolean) { - notifyAll { it.onDeviceVolumeChanged(eventTime, volume, muted) } - } - - override fun onVideoEnabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - notifyAll { it.onVideoEnabled(eventTime, decoderCounters) } - } - - override fun onVideoDecoderInitialized( - eventTime: AnalyticsListener.EventTime, - decoderName: String, - initializedTimestampMs: Long, - initializationDurationMs: Long - ) { - notifyAll { it.onVideoDecoderInitialized(eventTime, decoderName, initializedTimestampMs, initializationDurationMs) } - } - - override fun onVideoInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - notifyAll { it.onVideoInputFormatChanged(eventTime, format, decoderReuseEvaluation) } - } - - override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) { - notifyAll { it.onDroppedVideoFrames(eventTime, droppedFrames, elapsedMs) } - } - - override fun onVideoDecoderReleased(eventTime: AnalyticsListener.EventTime, decoderName: String) { - notifyAll { it.onVideoDecoderReleased(eventTime, decoderName) } - } - - override fun onVideoDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - notifyAll { it.onVideoDisabled(eventTime, decoderCounters) } - } - - override fun onVideoFrameProcessingOffset( - eventTime: AnalyticsListener.EventTime, - totalProcessingOffsetUs: Long, - frameCount: Int - ) { - notifyAll { it.onVideoFrameProcessingOffset(eventTime, totalProcessingOffsetUs, frameCount) } - } - - override fun onVideoCodecError(eventTime: AnalyticsListener.EventTime, videoCodecError: Exception) { - notifyAll { it.onVideoCodecError(eventTime, videoCodecError) } - } - - override fun onRenderedFirstFrame(eventTime: AnalyticsListener.EventTime, output: Any, renderTimeMs: Long) { - notifyAll { it.onRenderedFirstFrame(eventTime, output, renderTimeMs) } - } - - override fun onVideoSizeChanged(eventTime: AnalyticsListener.EventTime, videoSize: VideoSize) { - notifyAll { it.onVideoSizeChanged(eventTime, videoSize) } - } - - override fun onSurfaceSizeChanged(eventTime: AnalyticsListener.EventTime, width: Int, height: Int) { - notifyAll { it.onSurfaceSizeChanged(eventTime, width, height) } - } - - override fun onDrmSessionAcquired(eventTime: AnalyticsListener.EventTime, state: Int) { - notifyAll { it.onDrmSessionAcquired(eventTime, state) } - } - - override fun onDrmKeysLoaded(eventTime: AnalyticsListener.EventTime) { - notifyAll { it.onDrmKeysLoaded(eventTime) } - } - - override fun onDrmSessionManagerError(eventTime: AnalyticsListener.EventTime, error: Exception) { - notifyAll { it.onDrmSessionManagerError(eventTime, error) } - } - - override fun onDrmKeysRestored(eventTime: AnalyticsListener.EventTime) { - notifyAll { it.onDrmKeysRestored(eventTime) } - } - - override fun onDrmKeysRemoved(eventTime: AnalyticsListener.EventTime) { - notifyAll { it.onDrmKeysRemoved(eventTime) } - } - - override fun onDrmSessionReleased(eventTime: AnalyticsListener.EventTime) { - notifyAll { it.onDrmSessionReleased(eventTime) } - } - - override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { - notifyAll { it.onPlayerReleased(eventTime) } - } - - override fun onEvents(player: Player, events: AnalyticsListener.Events) { - notifyAll { it.onEvents(player, events) } - } - - class DummyTimeline(private val mediaItem: MediaItem) : Timeline() { - - override fun getWindowCount(): Int { - return 1 - } - - override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { - window.mediaItem = mediaItem - return window - } - - override fun getPeriodCount(): Int { - return 0 - } - - override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { - return Period() - } - - override fun getIndexOfPeriod(uid: Any): Int { - return 0 - } - - override fun getUidOfPeriod(periodIndex: Int): Any { - return Any() - } - } - - companion object { - - fun createPositionInfo(mediaItem: MediaItem?, index: Int = 0): PositionInfo { - return PositionInfo(null, index, mediaItem, null, 0, 0, 0, 0, 0) - } - - fun createEventTime(mediaItem: MediaItem, index: Int = 0): AnalyticsListener.EventTime { - val timeline = DummyTimeline(mediaItem) - return AnalyticsListener.EventTime(0, timeline, index, null, 0, timeline, index, null, 0, 0) - } - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt index 957286c0f..598801b36 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt @@ -57,14 +57,18 @@ class PillarboxMediaSource( /** * Can update media item * - * FIXME Test when using MediaController. + * TODO Test when using MediaController or MediaBrowser. * - * @param mediaItem - * @return + * @param mediaItem The new mediaItem, this method is called when we replace media item. + * @return true if the media can be update without reloading the media source. */ override fun canUpdateMediaItem(mediaItem: MediaItem): Boolean { - return mediaItem.mediaId == this.mediaItem.mediaId && - mediaItem.localConfiguration == this.mediaItem.localConfiguration + val currentItemWithoutTrackerData = this.mediaItem.buildUpon().setTag(null).build() + val mediaItemWithoutTrackerData = mediaItem.buildUpon().setTag(null).build() + return !( + currentItemWithoutTrackerData.mediaId != mediaItemWithoutTrackerData.mediaId || + currentItemWithoutTrackerData.localConfiguration != mediaItemWithoutTrackerData.localConfiguration + ) } override fun updateMediaItem(mediaItem: MediaItem) { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt index 45b2af587..4c3d11933 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt @@ -4,13 +4,14 @@ */ package ch.srgssr.pillarbox.player.tracker +import android.util.Log import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.common.Timeline.Window import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.StringUtil @@ -19,21 +20,19 @@ import ch.srgssr.pillarbox.player.utils.StringUtil * Current media item tracker * * Track current media item transition or lifecycle. - * Tracking session start when current item changed and it is loaded. + * Tracking session start when current item changed and have [MediaItemTrackerData] set. * Tracking session stop when current item changed or when it reached the end of lifecycle. * * MediaItem asynchronously call this callback after loaded * - onTimelineChanged with reason = [Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE] * - * A MediaItem is considered loaded when it has [MediaItem.LocalConfiguration] not null and it has a tag as [MediaItemTrackerData] - * * @param player The Player for which the current media item must be tracked. * @param mediaItemTrackerProvider The MediaItemTrackerProvider that provide new instance of [MediaItemTracker]. */ internal class CurrentMediaItemTracker internal constructor( private val player: ExoPlayer, private val mediaItemTrackerProvider: MediaItemTrackerProvider -) : AnalyticsListener { +) : Player.Listener { /** * Trackers are null if tracking session is stopped! @@ -50,153 +49,150 @@ internal class CurrentMediaItemTracker internal constructor( set(value) { if (field == value) return field = value - if (field) { - currentMediaItem = player.currentMediaItem - if (currentMediaItem.canHaveTrackingSession()) { - currentMediaItem?.let { startNewSession(it) } - } - } else { - trackers?.let { stopSession(MediaItemTracker.StopReason.Stop, player.currentPosition) } - } + setMediaItem(player.currentMediaItem) } private val window = Window() init { - player.addAnalyticsListener(this) + // player.addAnalyticsListener(this) + player.addListener(this) player.currentMediaItem?.let { startNewSession(it) } } - private fun stopSession(stopReason: MediaItemTracker.StopReason, positionMs: Long) { - if (currentMediaItem.canHaveTrackingSession()) { - trackers?.let { - for (tracker in it.list) { - tracker.stop(player, stopReason, positionMs) - } + /** + * Set media item if has not tracking data, set to null + */ + private fun setMediaItem(mediaItem: MediaItem?) { + if (enabled && mediaItem.canHaveTrackingSession()) { + if (!areEqual(currentMediaItem, mediaItem)) { + currentItemChange(currentMediaItem, mediaItem) + currentMediaItem = mediaItem } - } - trackers = null - this.currentMediaItem = null - } - - private fun startNewSession(mediaItem: MediaItem?) { - currentMediaItem?.let { stopSession(MediaItemTracker.StopReason.Stop, player.currentPosition) } - currentMediaItem = mediaItem - if (enabled && mediaItem.isLoaded()) { + } else { currentMediaItem?.let { - startSessionInternal(it) + stopSession(MediaItemTracker.StopReason.Stop, player.currentPosition) } } } - private fun updateOrStartSession(mediaItem: MediaItem?) { - if (!enabled) { + private fun currentItemChange(lastMediaItem: MediaItem?, newMediaItem: MediaItem?) { + require(newMediaItem.canHaveTrackingSession()) + if (newMediaItem == null) { + stopSession(MediaItemTracker.StopReason.Stop) return } - require(areEqual(currentMediaItem, mediaItem)) - if (currentMediaItem.isLoaded() != mediaItem.isLoaded()) { - currentMediaItem = mediaItem - currentMediaItem?.let { startNewSession(it) } + if (lastMediaItem == null) { + startNewSession(newMediaItem) + return + } + if (lastMediaItem.mediaId == newMediaItem.mediaId || lastMediaItem.getMediaItemTrackerData() != newMediaItem.getMediaItemTrackerData()) { + maybeUpdateData(lastMediaItem, newMediaItem) } else { - updateSessionInternal() + stopSession(MediaItemTracker.StopReason.Stop) + startNewSession(newMediaItem) } } - private fun updateSessionInternal() { + /** + * Maybe update data + * + * Don't start or stop if new tracker data is added. Only update existing trackers with new data. + * @param lastMediaItem + * @param newMediaItem + */ + private fun maybeUpdateData(lastMediaItem: MediaItem, newMediaItem: MediaItem) { + Log.i(TAG, "maybe update data from ${toStringMediaItem(lastMediaItem)} = >${toStringMediaItem(newMediaItem)}") + val lastTrackerData = lastMediaItem.getMediaItemTrackerData() + val newTrackerData = newMediaItem.getMediaItemTrackerData() trackers?.let { - for (tracker in it.list) { - currentMediaItem?.getMediaItemTrackerDataOrNull()?.getData(tracker)?.let { data -> - tracker.update(data) + for (tracker in it) { + val newData = newTrackerData.getData(tracker) ?: return + val oldData = lastTrackerData.getData(tracker) + if (newData != oldData) { + tracker.update(newData) } } } } - private fun startSessionInternal(mediaItem: MediaItem) { + private fun stopSession(stopReason: MediaItemTracker.StopReason, positionMs: Long = player.currentPosition) { + trackers?.let { + Log.i(TAG, "stop session $stopReason for ${toStringMediaItem(currentMediaItem)} deleting trackers") + for (tracker in it.list) { + tracker.stop(player, stopReason, positionMs) + } + } + trackers = null + currentMediaItem = null + } + + private fun startNewSession(mediaItem: MediaItem) { + if (!enabled) return + Log.i(TAG, "start new session for ${toStringMediaItem(mediaItem)} create trackers") require(trackers == null) - mediaItem.getMediaItemTrackerDataOrNull()?.let { + mediaItem.getMediaItemTrackerData().also { trackerData -> val trackers = MediaItemTrackerList() // Create each tracker for this new MediaItem - for (trackerType in it.trackers) { + for (trackerType in trackerData.trackers) { val tracker = mediaItemTrackerProvider.getMediaItemTrackerFactory(trackerType).create() trackers.append(tracker) - tracker.start(player, it.getData(tracker)) + tracker.start(player, trackerData.getData(tracker)) } this.trackers = trackers } } - private fun updateCurrentItemFromEventTime(eventTime: EventTime) { - val localItem = if (eventTime.timeline.isEmpty) { - null - } else { - eventTime.timeline.getWindow(eventTime.windowIndex, window) - val mediaItem = window.mediaItem - mediaItem - } - // Current item changed - if (!areEqual(localItem, currentMediaItem)) { - startNewSession(localItem) - } else { - updateOrStartSession(localItem) - } - } - - override fun onTimelineChanged(eventTime: EventTime, reason: Int) { - DebugLogger.debug(TAG, "onTimelineChanged current = ${toStringMediaItem(currentMediaItem)} ${StringUtil.timelineChangeReasonString(reason)}") - if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { - updateCurrentItemFromEventTime(eventTime) - } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + DebugLogger.debug( + TAG, "-- onTimelineChanged current = ${toStringMediaItem(player.currentMediaItem)} ${StringUtil.timelineChangeReasonString(reason)}" + ) + setMediaItem(player.currentMediaItem) } - override fun onPlaybackStateChanged(eventTime: EventTime, state: Int) { - DebugLogger.debug(TAG, "onPlaybackStateChanged ${StringUtil.playerStateString(state)}") - when (state) { - Player.STATE_IDLE -> stopSession(MediaItemTracker.StopReason.Stop, player.currentPosition) - Player.STATE_ENDED -> stopSession(MediaItemTracker.StopReason.EoF, player.currentPosition) - Player.STATE_READY -> { - updateCurrentItemFromEventTime(eventTime) + override fun onPlaybackStateChanged(playbackState: Int) { + DebugLogger.debug( + TAG, + "-- onPlaybackStateChanged ${StringUtil.playerStateString(playbackState)} current = ${toStringMediaItem(player.currentMediaItem)}" + ) + when (playbackState) { + Player.STATE_ENDED -> stopSession(MediaItemTracker.StopReason.EoF) + Player.STATE_IDLE -> stopSession(MediaItemTracker.StopReason.Stop) + else -> { + // Nothing } } } - override fun onPositionDiscontinuity( - eventTime: EventTime, - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { DebugLogger.debug( TAG, - "onPositionDiscontinuity (${oldPosition.mediaItemIndex}, ${oldPosition.positionMs}) " + - "=> (${newPosition.mediaItemIndex}, ${newPosition.positionMs})" + "-- onPositionDiscontinuity (${oldPosition.mediaItemIndex}, ${oldPosition.positionMs}) => (${newPosition.mediaItemIndex}, ${ + newPosition.positionMs + })" ) val oldPositionMs = oldPosition.positionMs when (reason) { - Player.DISCONTINUITY_REASON_REMOVE -> stopSession(MediaItemTracker.StopReason.Stop, oldPositionMs) + Player.DISCONTINUITY_REASON_REMOVE -> { + stopSession(MediaItemTracker.StopReason.Stop, oldPositionMs) + } + Player.DISCONTINUITY_REASON_AUTO_TRANSITION -> stopSession(MediaItemTracker.StopReason.EoF, oldPositionMs) - Player.DISCONTINUITY_REASON_SEEK, Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT, Player.DISCONTINUITY_REASON_INTERNAL, Player - .DISCONTINUITY_REASON_SKIP -> { - if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) { - stopSession(MediaItemTracker.StopReason.Stop, oldPositionMs) - } + else -> { + // Nothing } } } /** - * On media item transition is called just after onPositionDiscontinuity + * On media item transition + * + * @param mediaItem maybe null when playlist become empty + * @param reason */ - override fun onMediaItemTransition(eventTime: EventTime, mediaItem: MediaItem?, reason: Int) { - DebugLogger.debug(TAG, "onMediaItemTransition ${toStringMediaItem(mediaItem)} ${StringUtil.mediaItemTransitionReasonString(reason)} ") - mediaItem?.let { startNewSession(mediaItem) } - } - - /* - * Strange behaviors during buffering onPlayerReleased is called but the listener is not removed? - */ - override fun onPlayerReleased(eventTime: EventTime) { - DebugLogger.debug(TAG, "onPlayerReleased") + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + DebugLogger.debug(TAG, "-- onMediaItemTransition ${toStringMediaItem(mediaItem)} ${StringUtil.mediaItemTransitionReasonString(reason)} ") + setMediaItem(player.currentMediaItem) } internal companion object { @@ -214,14 +210,10 @@ internal class CurrentMediaItemTracker internal constructor( return when { m1 == null && m2 == null -> true m1 == null || m2 == null -> false - else -> m1.getIdentifier() == m2.getIdentifier() + else -> m1.getIdentifier() == m2.getIdentifier() && m1.localConfiguration == m2.localConfiguration } } - private fun MediaItem?.isLoaded(): Boolean { - return this?.localConfiguration != null - } - private fun MediaItem?.canHaveTrackingSession(): Boolean { return this?.getMediaItemTrackerDataOrNull() != null } @@ -231,7 +223,7 @@ internal class CurrentMediaItemTracker internal constructor( } private fun toStringMediaItem(mediaItem: MediaItem?): String { - return "media id = ${mediaItem?.mediaId} loaded = ${mediaItem?.localConfiguration?.uri != null}" + return "media id = ${mediaItem?.mediaId} tracker data = ${mediaItem?.getMediaItemTrackerDataOrNull()}" } } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerTest.kt deleted file mode 100644 index 169c13bd2..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerTest.kt +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.tracker - -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.ExoPlayer -import androidx.test.ext.junit.runners.AndroidJUnit4 -import ch.srgssr.pillarbox.player.test.utils.AnalyticsListenerCommander -import io.mockk.clearAllMocks -import io.mockk.every -import io.mockk.mockk -import org.junit.runner.RunWith -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@RunWith(AndroidJUnit4::class) -class CurrentMediaItemTrackerTest { - private lateinit var analyticsCommander: AnalyticsListenerCommander - private lateinit var currentItemTracker: CurrentMediaItemTracker - private lateinit var tracker: TestTracker - - @BeforeTest - fun setUp() { - analyticsCommander = AnalyticsListenerCommander(exoplayer = mockk()) - every { analyticsCommander.currentMediaItem } returns null - every { analyticsCommander.currentPosition } returns 1000L - tracker = TestTracker() - currentItemTracker = CurrentMediaItemTracker( - player = analyticsCommander, - mediaItemTrackerProvider = MediaItemTrackerRepository().apply { - registerFactory( - TestTracker::class.java, - object : MediaItemTracker.Factory { - override fun create(): MediaItemTracker { - return tracker - } - } - ) - } - ) - } - - @AfterTest - fun tearDown() { - clearAllMocks() - } - - @Test - fun `simple MediaItem without tracker`() { - val mediaItem = createMediaItemWithoutTracker("M1") - val expected = listOf(EventState.IDLE) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `MediaItem without tracker`() { - val mediaItem = createMediaItemWithoutTracker("M1", "testItemWithoutTracker") - val expected = listOf(EventState.IDLE) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `MediaItem load asynchronously without tracker`() { - val mediaItemEmpty = MediaItem.Builder().setMediaId("M1").build() - val mediaItemLoaded = createMediaItemWithoutTracker("M1", "testItemWithoutTracker") - val expected = listOf(EventState.IDLE) - analyticsCommander.simulateItemStart(mediaItemEmpty) - analyticsCommander.simulateItemLoaded(mediaItemLoaded) - analyticsCommander.simulateItemEnd(mediaItemLoaded) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start end`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.EOF) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start asynchronous loading end`() { - val mediaItemEmpty = MediaItem.Builder().setMediaId("M1").build() - val mediaItemLoaded = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.EOF) - analyticsCommander.simulateItemStart(mediaItemEmpty) - analyticsCommander.simulateItemLoaded(mediaItemLoaded) - analyticsCommander.simulateItemEnd(mediaItemLoaded) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start asynchronous loading release`() { - val mediaItemEmpty = MediaItem.Builder().setMediaId("M1").build() - val mediaItemLoaded = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.END) - analyticsCommander.simulateItemStart(mediaItemEmpty) - analyticsCommander.simulateItemLoaded(mediaItemLoaded) - analyticsCommander.simulateRelease(mediaItemLoaded) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start release`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.END) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateRelease(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun release() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE) - analyticsCommander.simulateRelease(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `restart after end`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.EOF, EventState.START, EventState.EOF) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - analyticsCommander.simulatedReady(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - analyticsCommander.simulateRelease(mediaItem) - - assertEquals(expected, tracker.stateList) - } - - @Test - fun `media transition seek to next`() { - val mediaItem = createMediaItemWithMediaId("M1") - val mediaItem2 = createMediaItemWithMediaId("M2") - val expectedStates = listOf(EventState.IDLE, EventState.START, EventState.END, EventState.START, EventState.EOF) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionSeek(mediaItem, mediaItem2) - analyticsCommander.simulateItemEnd(mediaItem2) - assertEquals(expectedStates, tracker.stateList, "Different Item") - tracker.clear() - - val mediaItem3 = createMediaItemWithMediaId("M1") - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionSeek(mediaItem, mediaItem3) - analyticsCommander.simulateItemEnd(mediaItem3) - assertEquals(expectedStates, tracker.stateList, "Different Item but equal") - } - - @Test - fun `media transition with asynchronous item`() { - val mediaItem = createMediaItemWithMediaId("M1") - val mediaItem2 = MediaItem.Builder().setMediaId("M2").build() - val mediaItem2Loaded = createMediaItemWithMediaId("M2") - val expectedStates = listOf(EventState.IDLE, EventState.START, EventState.END, EventState.START, EventState.END) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionSeek(mediaItem, mediaItem2) - assertEquals(listOf(EventState.IDLE, EventState.START, EventState.END), tracker.stateList) - - analyticsCommander.simulateItemLoaded(mediaItem2Loaded) - analyticsCommander.simulateRelease(mediaItem2Loaded) - assertEquals(expectedStates, tracker.stateList) - } - - @Test - fun `item without tracker toggle analytics`() { - val mediaItem = createMediaItemWithoutTracker("M1", "testItemWithoutTracker") - val expected = listOf(EventState.IDLE) - currentItemTracker.enabled = true - analyticsCommander.simulateItemStart(mediaItem) - currentItemTracker.enabled = false - currentItemTracker.enabled = true - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `media transition same item auto`() { - val mediaItem = createMediaItemWithMediaId("M1") - val mediaItem2 = createMediaItemWithMediaId("M2") - val expectedStates = listOf(EventState.IDLE, EventState.START, EventState.EOF, EventState.START, EventState.EOF) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionAuto(mediaItem, mediaItem2) - analyticsCommander.simulateItemEnd(mediaItem2) - assertEquals(expectedStates, tracker.stateList, "Different Item") - tracker.clear() - - val mediaItem3 = createMediaItemWithMediaId("M1") - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionAuto(mediaItem, mediaItem3) - analyticsCommander.simulateItemEnd(mediaItem3) - assertEquals(expectedStates, tracker.stateList, "Different Item but equal") - } - - @Test - fun `media transition repeat`() { - val expectedStates = listOf(EventState.IDLE, EventState.START, EventState.EOF, EventState.START) - val mediaItem = createMediaItemWithMediaId("M1") - - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemTransitionRepeat(mediaItem) - - assertEquals(expectedStates, tracker.stateList) - } - - @Test - fun `multiple stops`() { - val mediaItem = createMediaItemWithMediaId("M1") - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - analyticsCommander.simulateRelease(mediaItem) - - val expected = listOf(EventState.IDLE, EventState.START, EventState.EOF) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start end disabled at start of analytics`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE) - currentItemTracker.enabled = false - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start end toggle analytics`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.END, EventState.START, EventState.EOF) - currentItemTracker.enabled = true - analyticsCommander.simulateItemStart(mediaItem) - every { analyticsCommander.currentMediaItem } returns mediaItem - currentItemTracker.enabled = false - currentItemTracker.enabled = true - analyticsCommander.simulateItemEnd(mediaItem) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start asynchronously loading toggle analytics`() { - val mediaItemEmpty = MediaItem.Builder().setMediaId("M1").build() - val mediaItemLoaded = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.END, EventState.START, EventState.EOF) - currentItemTracker.enabled = true - analyticsCommander.simulateItemStart(mediaItemEmpty) - analyticsCommander.simulateItemLoaded(mediaItemLoaded) - every { analyticsCommander.currentMediaItem } returns mediaItemLoaded - currentItemTracker.enabled = false - currentItemTracker.enabled = true - analyticsCommander.simulateItemEnd(mediaItemLoaded) - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start asynchronously loading end disabled at end`() { - val mediaItemEmpty = MediaItem.Builder().setMediaId("M1").build() - val mediaItemLoaded = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.EOF) - currentItemTracker.enabled = true - analyticsCommander.simulateItemStart(mediaItemEmpty) - analyticsCommander.simulateItemLoaded(mediaItemLoaded) - analyticsCommander.simulateItemEnd(mediaItemLoaded) - currentItemTracker.enabled = false - assertEquals(expected, tracker.stateList) - } - - @Test - fun `start remove item`() { - val mediaItem = createMediaItemWithMediaId("M1") - val expected = listOf(EventState.IDLE, EventState.START, EventState.END) - analyticsCommander.simulateItemStart(mediaItem) - analyticsCommander.simulateItemRemoved(mediaItem) - assertEquals(expected, tracker.stateList) - } - - private companion object { - private val uri = mockk() - - private fun createMediaItemWithMediaId(mediaId: String): MediaItem { - every { uri.toString() } returns "https://host/media.mp4" - return MediaItem.Builder() - .setUri(uri) - .setMediaId(mediaId) - .setTag(MediaItemTrackerData.Builder().apply { putData(TestTracker::class.java, mediaId) }.build()) - .build() - } - - private fun createMediaItemWithoutTracker(mediaId: String, customTag: String? = null): MediaItem { - every { uri.toString() } returns "https://host/media.mp4" - return MediaItem.Builder() - .setUri(uri) - .setMediaId(mediaId) - .setTag(customTag) - .build() - } - } - - private enum class EventState { - IDLE, START, END, EOF - } - - private class TestTracker : MediaItemTracker { - private val _stateList = mutableListOf(EventState.IDLE) - val stateList: List = _stateList - - fun clear() { - _stateList.clear() - _stateList.add(EventState.IDLE) - } - - override fun start(player: ExoPlayer, initialData: Any?) { - _stateList.add(EventState.START) - } - - override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { - when (reason) { - MediaItemTracker.StopReason.EoF -> _stateList.add(EventState.EOF) - MediaItemTracker.StopReason.Stop -> _stateList.add(EventState.END) - } - } - } -} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt new file mode 100644 index 000000000..e776cec1c --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.tracker + +import androidx.media3.common.MediaItem +import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData +import ch.srgssr.pillarbox.player.extension.setTrackerData + +class FakeMediaItemSource : MediaItemSource { + override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { + val trackerData = mediaItem.getMediaItemTrackerData() + val itemBuilder = if (mediaItem.localConfiguration == null) { + val url = when (mediaItem.mediaId) { + MEDIA_ID_1 -> URL_MEDIA_1 + MEDIA_ID_2 -> URL_MEDIA_2 + else -> URL_MEDIA_3 + } + mediaItem.buildUpon().setUri(url) + } else { + mediaItem.buildUpon() + } + + if (mediaItem.mediaId == MEDIA_ID_NO_TRACKING_DATA) return itemBuilder.build() + itemBuilder.setTrackerData( + trackerData.buildUpon().putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data(mediaItem.mediaId)).build() + ) + return itemBuilder.build() + } + + companion object { + const val MEDIA_ID_1 = "media:1" + const val MEDIA_ID_2 = "media:2" + const val MEDIA_ID_NO_TRACKING_DATA = "media:3" + + const val URL_MEDIA_1 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" + const val URL_MEDIA_2 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" + const val URL_MEDIA_3 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" + + const val NEAR_END_POSITION_MS = 15_000L // the video has 17 sec duration + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt new file mode 100644 index 000000000..fd1c81379 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.tracker + +import androidx.media3.exoplayer.ExoPlayer + +class FakeMediaItemTracker : MediaItemTracker { + data class Data(val id: String) + + override fun start(player: ExoPlayer, initialData: Any?) { + require(initialData is Data) + } + + override fun update(data: Any) { + require(data is Data) + } + + override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { + // Nothing + } +} + +class FakeTrackerProvider(private val fakeMediaItemTracker: FakeMediaItemTracker) : MediaItemTrackerProvider { + override fun getMediaItemTrackerFactory(trackerClass: Class<*>): MediaItemTracker.Factory { + return object : MediaItemTracker.Factory { + override fun create(): MediaItemTracker { + return fakeMediaItemTracker + } + } + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt new file mode 100644 index 000000000..07f853ff0 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt @@ -0,0 +1,641 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.tracker + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.util.Assertions +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.RobolectricUtil +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.PillarboxPlayer +import ch.srgssr.pillarbox.player.SeekIncrement +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull +import ch.srgssr.pillarbox.player.extension.setTrackerData +import io.mockk.clearAllMocks +import io.mockk.confirmVerified +import io.mockk.spyk +import io.mockk.verify +import io.mockk.verifyAll +import io.mockk.verifyOrder +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class MediaItemTrackerTest { + + private lateinit var player: PillarboxPlayer + private lateinit var fakeMediaItemTracker: FakeMediaItemTracker + private lateinit var fakeClock: FakeClock + + @Before + fun createPlayer() { + val context = ApplicationProvider.getApplicationContext() + fakeMediaItemTracker = spyk(FakeMediaItemTracker()) + fakeClock = FakeClock(true) + player = PillarboxPlayer( + context = context, + dataSourceFactory = DefaultHttpDataSource.Factory(), + seekIncrement = SeekIncrement(), + loadControl = DefaultLoadControl(), + clock = fakeClock, + mediaItemSource = FakeMediaItemSource(), + mediaItemTrackerProvider = FakeTrackerProvider(fakeMediaItemTracker) + ) + } + + @After + fun releasePlayer() { + clearAllMocks() + player.release() + } + + @Test + fun `Player toggle tracking enabled call stop`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.trackingEnabled = false + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `Player toggle tracking enabled true false call stop start`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.trackingEnabled = false + player.trackingEnabled = true + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + } + verify(exactly = 0) { + fakeMediaItemTracker.update(any()) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `one MediaItem with mediaId set reach EoF`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.EoF, player.currentPosition) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `one MediaItem with mediaId set reach stop`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.stop() + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `one MediaItem with mediaId and url set reach eof`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .setUri(FakeMediaItemSource.URL_MEDIA_1) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.EoF, player.currentPosition) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `one MediaItem with mediaId and url set reach stop`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setUri(FakeMediaItemSource.URL_MEDIA_1) + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.stop() + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `Playlist of different items with media id and url set transition`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setUri(FakeMediaItemSource.URL_MEDIA_1) + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setUri(FakeMediaItemSource.URL_MEDIA_2) + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekToDefaultPosition(1) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= 1_000 + } + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `Playlist with items without tracking transition doesn't call start`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_NO_TRACKING_DATA + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekTo(1, FakeMediaItemSource.NEAR_END_POSITION_MS) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + } + verify(exactly = 0) { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `Playlist of different items with media id set transition`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.seekToDefaultPosition(1) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + verifyOrder { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `remove current item call stop`() { + val mediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(mediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.removeMediaItem(0) + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist remove current item start next item`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + player.removeMediaItem(0) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= 1_000 + } + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist replace current item by changing media meta data only`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(FakeMediaItemSource.MEDIA_ID_2) + .build() + ) + prepare() + play() + // seekTo(10_000) + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + // Wait MediaItemSource has load + RobolectricUtil.runMainLooperUntil { + player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null + } + val currentMediaItem = player.currentMediaItem!! + val mediaUpdate = currentMediaItem.buildUpon() + .setMediaMetadata(MediaMetadata.Builder().setTitle("New title").build()) + .build() + player.replaceMediaItem(player.currentMediaItemIndex, mediaUpdate) + // TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + verify(exactly = 0) { + fakeMediaItemTracker.update(any()) + } + verify(exactly = 1) { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist replace current item update current tracker with same data should not call update`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + RobolectricUtil.runMainLooperUntil { + player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null + } + val mediaItem = player.currentMediaItem + assertNotNull(mediaItem) + val mediaUpdate = mediaItem.buildUpon() + .setTrackerData( + mediaItem.getMediaItemTrackerData().buildUpon().build() + ) + .build() + player.replaceMediaItem(0, mediaUpdate) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + val waitToPosition = player.currentPosition + 1000 + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= waitToPosition + } + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + } + verify(exactly = 0) { + fakeMediaItemTracker.update(any()) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist replace current item update current tracker with null data should not call update`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + RobolectricUtil.runMainLooperUntil { + player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null + } + val mediaItem = player.currentMediaItem + assertNotNull(mediaItem) + val mediaUpdate = mediaItem.buildUpon() + .setTrackerData( + mediaItem.getMediaItemTrackerData().buildUpon() + .putData(FakeMediaItemTracker::class.java, null) + .build() + ) + .build() + player.replaceMediaItem(0, mediaUpdate) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + val waitToPosition = player.currentPosition + 1000 + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= waitToPosition + } + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + } + verify(exactly = 0) { + fakeMediaItemTracker.update(any()) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist replace current item update current tracker`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + RobolectricUtil.runMainLooperUntil { + player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null + } + val mediaItem = player.currentMediaItem + assertNotNull(mediaItem) + val mediaUpdate = mediaItem.buildUpon() + .setTrackerData( + mediaItem.getMediaItemTrackerData().buildUpon().putData( + FakeMediaItemTracker::class.java, + FakeMediaItemTracker.Data("New tracker data") + ).build() + ) + .build() + player.replaceMediaItem(0, mediaUpdate) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + val waitToPosition = player.currentPosition + 1000 + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= waitToPosition + } + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.update(FakeMediaItemTracker.Data("New tracker data")) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist auto transition stop current tracker`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + player.apply { + addMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build() + ) + addMediaItem( + MediaItem.Builder() + .setMediaId(secondMediaId) + .build() + ) + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + // player.seekTo(8_000) // Near the end of the media + runUntilMediaItemTransition(player) + // Wait second item is playing + val waitToPosition = player.currentPosition + 1000 + RobolectricUtil.runMainLooperUntil { + player.currentPosition >= waitToPosition + } + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.EoF, any()) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) + } + confirmVerified(fakeMediaItemTracker) + } + + @Test + fun `playlist repeat current item reset current tracker`() { + val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + player.apply { + setMediaItem( + MediaItem.Builder() + .setMediaId(firstMediaId) + .build(), + FakeMediaItemSource.NEAR_END_POSITION_MS + ) + player.repeatMode = Player.REPEAT_MODE_ONE + prepare() + play() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + runUntilMediaItemTransition(player) + player.stop() // Stop player to stop the auto repeat mode + + // Wait on item transition + // Stop otherwise goes crazy. + + verifyAll { + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.EoF, any()) + fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) + // player.stop + fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) + } + confirmVerified(fakeMediaItemTracker) + } + + companion object { + @Throws(TimeoutException::class) + fun runUntilMediaItemTransition(player: Player): Pair? { + // TestPlayerRunHelper.verifyMainTestThread(player) + val receivedEvent = AtomicReference?>() + val listener: Player.Listener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + receivedEvent.set(Pair(mediaItem, reason)) + } + } + player.addListener(listener) + RobolectricUtil.runMainLooperUntil { receivedEvent.get() != null || player.playerError != null } + player.removeListener(listener) + if (player.playerError != null) { + throw IllegalStateException(player.playerError) + } + return Assertions.checkNotNull(receivedEvent.get()) + } + } +}