diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 836a0a617..bb09d6069 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,6 +102,8 @@ androidx-media3-ui-leanback = { group = "androidx.media3", name = "media3-ui-lea androidx-media3-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "androidx-media3" } +androidx-media3-test-utils = { group = "androidx.media3", name = "media3-test-utils", version.ref = "androidx-media3" } +androidx-media3-test-utils-robolectric = { group = "androidx.media3", name = "media3-test-utils-robolectric", version.ref = "androidx-media3" } androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt index eb0890940..3aa923666 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt @@ -109,15 +109,17 @@ class MediaCompositionMediaItemSource( if (resource.tokenType == Resource.TokenType.AKAMAI) { uri = appendTokenQueryToUri(uri) } - val trackerData = mediaItem.getMediaItemTrackerData() - trackerDataProvider?.update(trackerData, resource, chapter, result) - trackerData.putData(SRGEventLoggerTracker::class.java, null) - getComScoreData(result, chapter, resource)?.let { - trackerData.putData(ComScoreTracker::class.java, it) - } - getCommandersActData(result, chapter, resource)?.let { - trackerData.putData(CommandersActTracker::class.java, it) - } + val trackerData = mediaItem.getMediaItemTrackerData().buildUpon().apply { + trackerDataProvider?.update(this, resource, chapter, result) + putData(SRGEventLoggerTracker::class.java, null) + getComScoreData(result, chapter, resource)?.let { + putData(ComScoreTracker::class.java, it) + } + getCommandersActData(result, chapter, resource)?.let { + putData(CommandersActTracker::class.java, it) + } + }.build() + return mediaItem.buildUpon() .setMediaMetadata(fillMetaData(mediaItem.mediaMetadata, chapter)) .setDrmConfiguration(fillDrmConfiguration(resource)) diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt index 854d20e20..806b1bd4c 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt @@ -16,13 +16,13 @@ interface TrackerDataProvider { /** * Update tracker data with given integration layer data. * - * @param trackerData The [MediaItemTrackerData] to update. + * @param trackerData The [MediaItemTrackerData.Builder] to update. * @param resource The selected [Resource]. * @param chapter The selected [Chapter]. * @param mediaComposition The loaded [MediaComposition]. */ fun update( - trackerData: MediaItemTrackerData, + trackerData: MediaItemTrackerData.Builder, resource: Resource, chapter: Chapter, mediaComposition: MediaComposition 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/build.gradle.kts b/pillarbox-player/build.gradle.kts index 34ade538d..2965d7cfb 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -82,8 +82,8 @@ dependencies { testImplementation(project(":pillarbox-player-testutils")) - testImplementation(libs.androidx.test.core) - testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.media3.test.utils) + testImplementation(libs.androidx.media3.test.utils.robolectric) testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt index e0172e54a..dc919c9ce 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt @@ -5,12 +5,14 @@ package ch.srgssr.pillarbox.player import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline.Window import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.util.Clock import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory @@ -86,8 +88,28 @@ class PillarboxPlayer internal constructor( loadControl: LoadControl = PillarboxLoadControl(), mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), seekIncrement: SeekIncrement = SeekIncrement() + ) : this( + context = context, + mediaItemSource = mediaItemSource, + dataSourceFactory = dataSourceFactory, + loadControl = loadControl, + mediaItemTrackerProvider = mediaItemTrackerProvider, + seekIncrement = seekIncrement, + clock = Clock.DEFAULT, + ) + + @VisibleForTesting + constructor( + context: Context, + mediaItemSource: MediaItemSource, + dataSourceFactory: DataSource.Factory, + loadControl: LoadControl, + mediaItemTrackerProvider: MediaItemTrackerProvider, + seekIncrement: SeekIncrement, + clock: Clock, ) : this( ExoPlayer.Builder(context) + .setClock(clock) .setUsePlatformDiagnostics(false) .setSeekIncrements(seekIncrement) .setRenderersFactory( diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt index 2ed36d498..50ffbd09c 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/extension/MediaItem.kt @@ -25,11 +25,11 @@ fun MediaItem.getMediaItemTrackerDataOrNull(): MediaItemTrackerData? { * @return current [MediaItemTrackerData] or create. */ fun MediaItem.getMediaItemTrackerData(): MediaItemTrackerData { - return getMediaItemTrackerDataOrNull() ?: MediaItemTrackerData() + return getMediaItemTrackerDataOrNull() ?: MediaItemTrackerData.EMPTY } /** - * Set tracker data. + * Set tracker data. This method should only be called if {@link #setUri} is passed a non-null value. * @see MediaItem.Builder.setTag * @param trackerData Set trackerData to [MediaItem.Builder.setTag]. * @return [MediaItem.Builder] for convenience 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..d2920c38a 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 @@ -7,33 +7,28 @@ package ch.srgssr.pillarbox.player.tracker import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.common.Timeline.Window +import androidx.media3.common.Timeline 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 /** * 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,158 +45,123 @@ 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.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 != null && 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) { + if (lastMediaItem == null) { + startNewSession(newMediaItem) return } - require(areEqual(currentMediaItem, mediaItem)) - if (currentMediaItem.isLoaded() != mediaItem.isLoaded()) { - currentMediaItem = mediaItem - currentMediaItem?.let { startNewSession(it) } + 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. + */ + private fun maybeUpdateData(lastMediaItem: MediaItem, newMediaItem: MediaItem) { trackers?.let { - for (tracker in it.list) { - currentMediaItem?.getMediaItemTrackerDataOrNull()?.getData(tracker)?.let { data -> - tracker.update(data) + val lastTrackerData = lastMediaItem.getMediaItemTrackerData() + val newTrackerData = newMediaItem.getMediaItemTrackerData() + for (tracker in it) { + val newData = newTrackerData.getData(tracker) ?: continue + 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 { + for (tracker in it) { + tracker.stop(player, stopReason, positionMs) + } + } + trackers = null + currentMediaItem = null + } + + private fun startNewSession(mediaItem: MediaItem) { + if (!enabled) return 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) { + 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) { + 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 - ) { - DebugLogger.debug( - TAG, - "onPositionDiscontinuity (${oldPosition.mediaItemIndex}, ${oldPosition.positionMs}) " + - "=> (${newPosition.mediaItemIndex}, ${newPosition.positionMs})" - ) + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { val oldPositionMs = oldPosition.positionMs when (reason) { 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) { + setMediaItem(player.currentMediaItem) } internal companion object { - private const val TAG = "CurrentItemTracker" - /** * Are equals only checks mediaId and localConfiguration.uri * @@ -214,24 +174,16 @@ 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 + private fun MediaItem.canHaveTrackingSession(): Boolean { + return this.getMediaItemTrackerDataOrNull() != null } private fun MediaItem.getIdentifier(): String? { return if (mediaId == MediaItem.DEFAULT_MEDIA_ID) localConfiguration?.uri?.toString() else mediaId } - - private fun toStringMediaItem(mediaItem: MediaItem?): String { - return "media id = ${mediaItem?.mediaId} loaded = ${mediaItem?.localConfiguration?.uri != null}" - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt index 96c9a090c..735cd6fb6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt @@ -5,12 +5,9 @@ package ch.srgssr.pillarbox.player.tracker /** - * MediaItem tracker data. - * - * @constructor Create empty Tracker data + * Immutable MediaItem tracker data. */ -class MediaItemTrackerData { - private val map = HashMap, Any?>() +class MediaItemTrackerData private constructor(private val map: Map, Any?>) { /** * List of tracker class that have data. @@ -43,13 +40,61 @@ class MediaItemTrackerData { } /** - * Put data for trackerClass + * Build upon * - * @param T extends [MediaItemTracker]. - * @param trackerClass The class of the [MediaItemTracker]. - * @param data The data to associated with any instance of trackerClass. + * @return A builder filled with current data. */ - fun putData(trackerClass: Class, data: Any? = null) { - map[trackerClass] = data + fun buildUpon(): Builder = Builder(this) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MediaItemTrackerData + + return map == other.map + } + + override fun hashCode(): Int { + return map.hashCode() + } + + override fun toString(): String { + return "MediaItemTrackerData(map=$map)" + } + + companion object { + /** + * Empty [MediaItemTrackerData]. + */ + val EMPTY = MediaItemTrackerData(emptyMap()) + } + + /** + * Builder + *y + * @param source set this builder with source value. + */ + class Builder(source: MediaItemTrackerData = EMPTY) { + private val map = HashMap, Any?>(source.map) + + /** + * Put data for trackerClass + * + * @param T extends [MediaItemTracker]. + * @param trackerClass The class of the [MediaItemTracker]. + * @param data The data to associated with any instance of trackerClass. + */ + fun putData(trackerClass: Class, data: Any? = null): Builder { + map[trackerClass] = data + return this + } + + /** + * Build + * + * @return a new instance of [MediaItemTrackerData] + */ + fun build(): MediaItemTrackerData = MediaItemTrackerData(map.toMap()) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/MediaItemTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/MediaItemTest.kt index 1fee2cbe6..8065fe687 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/MediaItemTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/extension/MediaItemTest.kt @@ -35,8 +35,9 @@ class MediaItemTest { @Test fun `getMediaItemTrackerData with tag set`() { - val mediaItemTrackerData = MediaItemTrackerData() - mediaItemTrackerData.putData(MediaItemTracker::class.java) + val mediaItemTrackerData = MediaItemTrackerData.Builder() + .putData(MediaItemTracker::class.java) + .build() val mediaItem = MediaItem.Builder() .setUri(mockk()) diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt new file mode 100644 index 000000000..ea68613fc --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt @@ -0,0 +1,190 @@ +/* + * 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 androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.ExoPlayer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData +import ch.srgssr.pillarbox.player.extension.setTrackerData +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class CurrentMediaItemTrackerAreEqualTest { + + @Test + fun `areEqual both mediaItem are null`() { + assertTrue(CurrentMediaItemTracker.areEqual(null, null)) + } + + @Test + fun `areEqual first mediaItem is null`() { + assertFalse(CurrentMediaItemTracker.areEqual(null, MediaItem.EMPTY)) + } + + @Test + fun `areEqual second mediaItem is null`() { + assertFalse(CurrentMediaItemTracker.areEqual(MediaItem.EMPTY, null)) + } + + @Test + fun `areEqual with different media id without tag and url`() { + val mediaItem = createMediaItemWithMediaId("M1") + val mediaItem2 = createMediaItemWithMediaId("M2") + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with same media id without tag and url`() { + val mediaItem = createMediaItemWithMediaId("M1") + val mediaItem2 = createMediaItemWithMediaId("M1") + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with one default media id`() { + val mediaItem = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) + val mediaItem2 = createMediaItemWithMediaId("M1") + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with both default media id`() { + val mediaItem = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) + val mediaItem2 = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with same media id same url same tag`() { + val mediaId = "M1" + val url = "https://streaming.com/video.mp4" + val mediaItem = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = "Tag1") + val mediaItem2 = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = "Tag1") + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with same media id same url without tag`() { + val mediaId = "M1" + val url = "https://streaming.com/video.mp4" + val mediaItem = createMediaItemWithMediaId(mediaId = mediaId, url = url) + val mediaItem2 = createMediaItemWithMediaId(mediaId = mediaId, url = url) + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with same media id same url and different tag`() { + val mediaId = "M1" + val url = "https://streaming.com/video.mp4" + val mediaItem = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = null) + val mediaItem2 = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = "Tag2") + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual with same media id different url and same tag`() { + val mediaId = "M1" + val url = "https://streaming.com/video.mp4" + val mediaItem = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = "Tag1") + val mediaItem2 = createMediaItemWithMediaId(mediaId = mediaId, url = "https://streaming.com/video2.mp4", tag = "Tag1") + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual no media id same url different tag`() { + val mediaItem = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTag("Tag1") + .build() + + val mediaItem2 = mediaItem.buildUpon() + .setTag("Tag2") + .build() + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual same MediaItemTrackerData content`() { + val mediaItem = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) + .build() + + val mediaItem2 = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) + .build() + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual same MediaItemTrackerData`() { + val mediaItem = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) + .build() + + val mediaItem2 = mediaItem.buildUpon() + .setTrackerData(mediaItem.getMediaItemTrackerData().buildUpon().putData(Tracker::class.java, "data1").build()) + .build() + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areEqual same MediaItemTrackerData but different MediaMetadata`() { + val mediaItem = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) + .build() + + val mediaItem2 = mediaItem.buildUpon() + .setTrackerData(mediaItem.getMediaItemTrackerData().buildUpon().putData(Tracker::class.java, "data1").build()) + .setMediaMetadata(MediaMetadata.Builder().setTitle("New title").build()) + .build() + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + @Test + fun `areNotEqual different data`() { + val mediaItem = MediaItem.Builder() + .setUri("https://streaming.com/video.mp4") + .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) + .build() + + val mediaItem2 = mediaItem.buildUpon() + .setTrackerData(mediaItem.getMediaItemTrackerData().buildUpon().putData(Tracker::class.java, "data2").build()) + .build() + assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + } + + private class Tracker : MediaItemTracker { + override fun start(player: ExoPlayer, initialData: Any?) { + // Nothing + } + + override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { + // Nothing + } + } + + companion object { + private fun createMediaItemWithMediaId( + mediaId: String, + url: String? = null, + tag: Any? = null, + ): MediaItem { + return MediaItem.Builder() + .setUri(url) + .setMediaId(mediaId) + .setTag(tag) + .build() + } + } +} 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 8dec305bc..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerTest.kt +++ /dev/null @@ -1,381 +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 -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@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 `areEqual both mediaItem are null`() { - assertTrue(CurrentMediaItemTracker.areEqual(null, null)) - } - - @Test - fun `areEqual first mediaItem is null`() { - val mediaItem = createMediaItemWithMediaId("M1") - assertFalse(CurrentMediaItemTracker.areEqual(null, mediaItem)) - } - - @Test - fun `areEqual second mediaItem is null`() { - val mediaItem = createMediaItemWithMediaId("M1") - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, null)) - } - - @Test - fun `areEqual with different media id`() { - val mediaItem = createMediaItemWithMediaId("M1") - val mediaItem2 = createMediaItemWithMediaId("M2") - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) - } - - @Test - fun `areEqual with same media id`() { - val mediaItem = createMediaItemWithMediaId("M1") - val mediaItem2 = createMediaItemWithMediaId("M1") - assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) - } - - @Test - fun `areEqual with one default media id`() { - val mediaItem = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) - val mediaItem2 = createMediaItemWithMediaId("M1") - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) - } - - @Test - fun `areEqual with both default media id`() { - val mediaItem = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) - val mediaItem2 = createMediaItemWithMediaId(MediaItem.DEFAULT_MEDIA_ID) - assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) - } - - @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().apply { putData(TestTracker::class.java, mediaId) }) - .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..618a2f61e --- /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 = mediaItem.buildUpon() + + 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 + } + itemBuilder.setUri(url) + } + + 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..30f9fd1be --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt @@ -0,0 +1,39 @@ +/* + * 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 Factory(private val fakeMediaItemTracker: FakeMediaItemTracker) : MediaItemTracker.Factory { + override fun create(): MediaItemTracker { + return fakeMediaItemTracker + } + } +} + +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/MediaItemTrackerDataTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt index 82d3cc515..d78335f56 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt @@ -7,30 +7,68 @@ package ch.srgssr.pillarbox.player.tracker import androidx.media3.exoplayer.ExoPlayer import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue class MediaItemTrackerDataTest { @Test fun `media item tracker data`() { - val mediaItemTrackerData = MediaItemTrackerData() + val emptyMediaItemTrackerData = MediaItemTrackerData.EMPTY val mediaItemTracker1 = MediaItemTracker1() val mediaItemTracker2 = MediaItemTracker2() - assertTrue(mediaItemTrackerData.trackers.isEmpty()) - assertNull(mediaItemTrackerData.getData(mediaItemTracker1)) - assertNull(mediaItemTrackerData.getDataAs(mediaItemTracker1)) - assertNull(mediaItemTrackerData.getData(mediaItemTracker2)) - assertNull(mediaItemTrackerData.getDataAs(mediaItemTracker2)) + assertTrue(emptyMediaItemTrackerData.trackers.isEmpty()) + assertNull(emptyMediaItemTrackerData.getData(mediaItemTracker1)) + assertNull(emptyMediaItemTrackerData.getDataAs(mediaItemTracker1)) + assertNull(emptyMediaItemTrackerData.getData(mediaItemTracker2)) + assertNull(emptyMediaItemTrackerData.getDataAs(mediaItemTracker2)) - mediaItemTrackerData.putData(mediaItemTracker1::class.java, "Some value") - mediaItemTrackerData.putData(mediaItemTracker2::class.java) + val mediaItemTrackerDataUpdated = emptyMediaItemTrackerData.buildUpon() + .putData(mediaItemTracker1::class.java, "Some value") + .putData(mediaItemTracker2::class.java) + .build() - assertEquals(setOf(mediaItemTracker1::class.java, mediaItemTracker2::class.java), mediaItemTrackerData.trackers) - assertEquals("Some value", mediaItemTrackerData.getData(mediaItemTracker1)) - assertEquals("Some value", mediaItemTrackerData.getDataAs(mediaItemTracker1)) - assertNull(mediaItemTrackerData.getData(mediaItemTracker2)) - assertNull(mediaItemTrackerData.getDataAs(mediaItemTracker2)) + assertEquals(setOf(mediaItemTracker1::class.java, mediaItemTracker2::class.java), mediaItemTrackerDataUpdated.trackers) + assertEquals("Some value", mediaItemTrackerDataUpdated.getData(mediaItemTracker1)) + assertEquals("Some value", mediaItemTrackerDataUpdated.getDataAs(mediaItemTracker1)) + assertNull(mediaItemTrackerDataUpdated.getData(mediaItemTracker2)) + assertNull(mediaItemTrackerDataUpdated.getDataAs(mediaItemTracker2)) + } + + @Test + fun `empty media item tracker data are equals`() { + assertEquals(MediaItemTrackerData.EMPTY, MediaItemTrackerData.Builder().build()) + } + + @Test + fun `media item tracker data are equals`() { + val mediaItemTrackerData1 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data1") + .putData(MediaItemTracker2::class.java, "Data2") + .build() + val mediaItemTrackerData2 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data1") + .putData(MediaItemTracker2::class.java, "Data2") + .build() + assertEquals(mediaItemTrackerData1, mediaItemTrackerData2) + } + + @Test + fun `media item tracker data are not equals when data changes`() { + val mediaItemTrackerData1 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data1") + .putData(MediaItemTracker2::class.java, "Data2") + .build() + val mediaItemTrackerData2 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data1") + .build() + assertNotEquals(mediaItemTrackerData1, mediaItemTrackerData2) + val mediaItemTrackerData3 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data1") + val mediaItemTrackerData4 = MediaItemTrackerData.Builder() + .putData(MediaItemTracker1::class.java, "Data2") + assertNotEquals(mediaItemTrackerData3, mediaItemTrackerData4) } private open class EmptyMediaItemTracker : MediaItemTracker { 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..a7f31180a --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt @@ -0,0 +1,632 @@ +/* + * 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() + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + // Wait for MediaItemSource to be loaded + 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) + + 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) + TestPlayerRunHelper.runUntilTimelineChanged(player) + + verifyOrder { + 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) + private fun runUntilMediaItemTransition(player: Player): Pair { + 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()) + } + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt new file mode 100644 index 000000000..1794d6a61 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt @@ -0,0 +1,125 @@ +/* + * 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.Player +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.test.utils.FakeClock +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.data.MediaItemSource +import ch.srgssr.pillarbox.player.extension.setTrackerData +import io.mockk.clearAllMocks +import io.mockk.spyk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import kotlin.test.Test + +@RunWith(AndroidJUnit4::class) +class MultiMediaItemTrackerUpdate { + private lateinit var fakeClock: FakeClock + + @Before + fun createPlayer() { + fakeClock = FakeClock(true) + } + + @After + fun releasePlayer() { + clearAllMocks() + } + + @Test + fun `Remove one tracker data update other tracker data when initialized both in MediaItemSource`() { + val context = ApplicationProvider.getApplicationContext() + val fakeMediaItemTracker = spyk(FakeMediaItemTracker()) + val dummyMediaItemTracker = spyk(DummyTracker()) + + val player = PillarboxPlayer( + context = context, + dataSourceFactory = DefaultHttpDataSource.Factory(), + seekIncrement = SeekIncrement(), + loadControl = DefaultLoadControl(), + clock = fakeClock, + mediaItemSource = object : MediaItemSource { + override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { + val trackerData = MediaItemTrackerData.Builder() + .putData(DummyTracker::class.java, "DummyItemTracker") + .putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data("FakeMediaItemTracker")) + .build() + return mediaItem.buildUpon() + .setUri(FakeMediaItemSource.URL_MEDIA_1) + .setTrackerData(trackerData) + .build() + } + }, + mediaItemTrackerProvider = MediaItemTrackerRepository().apply { + registerFactory(DummyTracker::class.java, DummyTracker.Factory(dummyMediaItemTracker)) + registerFactory(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Factory(fakeMediaItemTracker)) + } + ) + player.apply { + player.setMediaItem( + MediaItem.Builder() + .setMediaId(FakeMediaItemSource.MEDIA_ID_1) + .build() + ) + prepare() + play() + } + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + val currentMediaItem = player.currentMediaItem!! + val mediaUpdate = currentMediaItem.buildUpon() + .setTrackerData( + MediaItemTrackerData.Builder() + .putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data("New Data")) + .build() + ) + .build() + player.replaceMediaItem(0, mediaUpdate) + + verify(exactly = 0) { + dummyMediaItemTracker.update(any()) + } + + verify(exactly = 1) { + dummyMediaItemTracker.start(any(), any()) + } + + verifyOrder { + fakeMediaItemTracker.start(any(), any()) + fakeMediaItemTracker.update(FakeMediaItemTracker.Data("New Data")) + } + player.release() + } + + internal class DummyTracker : MediaItemTracker { + + override fun start(player: ExoPlayer, initialData: Any?) { + // Nothing it is dummy + } + + override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { + // Nothing is is dummy + } + + class Factory(private val dummyTracker: DummyTracker = DummyTracker()) : MediaItemTracker.Factory { + override fun create(): MediaItemTracker { + return dummyTracker + } + } + } +}