Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
439 improve current media item tracker (#440)
Browse files Browse the repository at this point in the history
Co-authored-by: Gaëtan Muller <[email protected]>
StaehliJ and MGaetan89 authored Feb 15, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent b7ab56d commit f4532dd
Showing 18 changed files with 1,261 additions and 1,039 deletions.
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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

This file was deleted.

4 changes: 2 additions & 2 deletions pillarbox-player/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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(
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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) {
Original file line number Diff line number Diff line change
@@ -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}"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Class<*>, Any?>()
class MediaItemTrackerData private constructor(private val map: Map<Class<*>, 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 <T : MediaItemTracker> putData(trackerClass: Class<T>, 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<Class<*>, 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 <T : MediaItemTracker> putData(trackerClass: Class<T>, data: Any? = null): Builder {
map[trackerClass] = data
return this
}

/**
* Build
*
* @return a new instance of [MediaItemTrackerData]
*/
fun build(): MediaItemTrackerData = MediaItemTrackerData(map.toMap())
}
}
Original file line number Diff line number Diff line change
@@ -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<Uri>())
Original file line number Diff line number Diff line change
@@ -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()
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<Context>()
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
}
}
}
}

0 comments on commit f4532dd

Please sign in to comment.