Skip to content

Commit

Permalink
Extract analytics from CurrentMediaItemTracker (#483)
Browse files Browse the repository at this point in the history
  • Loading branch information
MGaetan89 authored Apr 5, 2024
1 parent 86f7dce commit d9e96c2
Show file tree
Hide file tree
Showing 12 changed files with 754 additions and 339 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class PillarboxAndroidApplicationPlugin : Plugin<Project> {
}

release {
signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.named("release").get()
isMinifyEnabled = false
isDebuggable = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.content.Context
import androidx.core.util.component1
import androidx.core.util.component2
import androidx.media3.common.PlaybackException
import androidx.media3.datasource.DataSourceException
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import ch.srgssr.pillarbox.core.business.exception.BlockReasonException
Expand Down Expand Up @@ -67,6 +68,15 @@ class SRGErrorMessageProviderTest {
assertEquals(exception.message, errorMessage)
}

@Test
fun `getErrorMessage DataSourceException`() {
val exception = DataSourceException(PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND)
val (errorCode, errorMessage) = errorMessageProvider.getErrorMessage(playbackException(exception))

assertEquals(PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, errorCode)
assertEquals(exception.message, errorMessage)
}

@Test
fun `getErrorMessage IOException`() {
val exception = IOException()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed
import ch.srgssr.pillarbox.player.extension.setPreferredAudioRoleFlagsToAccessibilityManagerSettings
import ch.srgssr.pillarbox.player.extension.setSeekIncrements
import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory
import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemTracker
import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker
import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemTagTracker
import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerProvider
import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository

Expand All @@ -37,11 +38,11 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository
*/
class PillarboxPlayer internal constructor(
private val exoPlayer: ExoPlayer,
mediaItemTrackerProvider: MediaItemTrackerProvider?
) :
ExoPlayer by exoPlayer, PillarboxExoPlayer {
mediaItemTrackerProvider: MediaItemTrackerProvider,
) : ExoPlayer by exoPlayer, PillarboxExoPlayer {
private val listeners = HashSet<PillarboxExoPlayer.Listener>()
private val itemTracker: CurrentMediaItemTracker?
private val itemTagTracker = CurrentMediaItemTagTracker(this)
private val analyticsTracker = AnalyticsMediaItemTracker(this, mediaItemTrackerProvider)
private val window = Window()
override var smoothSeekingEnabled: Boolean = false
set(value) {
Expand All @@ -61,17 +62,19 @@ class PillarboxPlayer internal constructor(
private var isSeeking: Boolean = false

/**
* Enable or disable MediaItem tracking
* Enable or disable analytics tracking for the current [MediaItem].
*/
var trackingEnabled: Boolean
set(value) = itemTracker?.let { it.enabled = value } ?: Unit
get() = itemTracker?.enabled ?: false
get() = analyticsTracker.enabled
set(value) {
analyticsTracker.enabled = value
}

init {
exoPlayer.addListener(ComponentListener())
itemTracker = mediaItemTrackerProvider?.let {
CurrentMediaItemTracker(this, it)
}

itemTagTracker.addCallback(analyticsTracker)

if (BuildConfig.DEBUG) {
addAnalyticsListener(EventLogger())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import androidx.media3.exoplayer.source.MediaSource
* @property mediaSourceFactory
* @constructor Create empty Asset loader
*/
abstract class AssetLoader(var mediaSourceFactory: MediaSource.Factory) {
abstract class AssetLoader(val mediaSourceFactory: MediaSource.Factory) {
/**
* Can load asset
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.player.tracker

import androidx.annotation.VisibleForTesting
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull
import ch.srgssr.pillarbox.player.tracker.MediaItemTracker.StopReason
import ch.srgssr.pillarbox.player.utils.DebugLogger
import ch.srgssr.pillarbox.player.utils.StringUtil
import kotlin.time.Duration.Companion.milliseconds

/**
* Custom [CurrentMediaItemTagTracker.Callback] to manage analytics.
*
* @param player The [Player] whose current [MediaItem] is tracked for analytics.
* @param mediaItemTrackerProvider The [MediaItemTrackerProvider] that provide new instance of [MediaItemTracker].
*/
internal class AnalyticsMediaItemTracker(
private val player: ExoPlayer,
private val mediaItemTrackerProvider: MediaItemTrackerProvider,
) : CurrentMediaItemTagTracker.Callback {
private val listener = CurrentMediaItemListener()

/**
* Trackers are empty if the tracking session is stopped.
*/
private var trackers = MediaItemTrackerList()

/**
* Current [MediaItem].
* Detect `mediaId` changes or URLs if no `mediaId`.
*/
private var currentMediaItem: MediaItem? = null

private var hasAnalyticsListener = false

var enabled: Boolean = true
set(value) {
if (field == value) {
return
}

field = value
if (field) {
player.currentMediaItem?.let { setMediaItem(it) }
} else {
stopSession(StopReason.Stop)
}
}

override fun onTagChanged(
mediaItem: MediaItem?,
tag: Any?,
) {
if (mediaItem == null) {
stopSession(StopReason.Stop)
} else if (tag != null) {
if (!hasAnalyticsListener) {
player.addAnalyticsListener(listener)

hasAnalyticsListener = true
}

setMediaItem(mediaItem)
}
}

private fun setMediaItem(mediaItem: MediaItem) {
if (!areEqual(mediaItem, currentMediaItem)) {
stopSession(StopReason.Stop)
}

if (mediaItem.canHaveTrackingSession() && currentMediaItem?.getMediaItemTrackerDataOrNull() == null) {
startNewSession(mediaItem)
}

// Update the current MediaItem with tracker data
this.currentMediaItem = mediaItem
}

private fun stopSession(
stopReason: StopReason,
positionMs: Long = player.currentPosition,
) {
DebugLogger.info(TAG, "Stop trackers $stopReason @${positionMs.milliseconds}")

for (tracker in trackers) {
tracker.stop(player, stopReason, positionMs)
}

trackers.clear()
currentMediaItem = null
}

private fun startNewSession(mediaItem: MediaItem) {
if (!enabled) {
return
}

require(trackers.isEmpty())

DebugLogger.info(TAG, "Start new session for ${mediaItem.prettyString()}")

// Create each tracker for this new MediaItem
val mediaItemTrackerData = mediaItem.getMediaItemTrackerDataOrNull() ?: return
val trackers = mediaItemTrackerData.trackers
.map { trackerType ->
mediaItemTrackerProvider.getMediaItemTrackerFactory(trackerType).create()
.also { it.start(player, mediaItemTrackerData.getData(it)) }
}

this.trackers.addAll(trackers)
}

private inner class CurrentMediaItemListener : AnalyticsListener {
override fun onPlaybackStateChanged(
eventTime: AnalyticsListener.EventTime,
@Player.State playbackState: Int,
) {
DebugLogger.debug(
TAG,
"onPlaybackStateChanged ${StringUtil.playerStateString(playbackState)} ${player.currentMediaItem?.prettyString()}"
)

when (playbackState) {
Player.STATE_ENDED -> stopSession(StopReason.EoF)
Player.STATE_IDLE -> stopSession(StopReason.Stop)
Player.STATE_READY -> {
if (currentMediaItem == null) {
player.currentMediaItem?.let { setMediaItem(it) }
}
}

else -> Unit
}
}

/*
* On position discontinuity handle stop session if required
*/
override fun onPositionDiscontinuity(
eventTime: AnalyticsListener.EventTime,
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
@Player.DiscontinuityReason reason: Int,
) {
DebugLogger.debug(
TAG,
"onPositionDiscontinuity ${StringUtil.discontinuityReasonString(reason)} ${player.currentMediaItem?.prettyString()}"
)

val oldPositionMs = oldPosition.positionMs
when (reason) {
Player.DISCONTINUITY_REASON_REMOVE -> stopSession(StopReason.Stop, oldPositionMs)
Player.DISCONTINUITY_REASON_AUTO_TRANSITION -> stopSession(StopReason.EoF, oldPositionMs)
else -> {
if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) {
stopSession(StopReason.Stop, oldPositionMs)
}
}
}
}

/*
* Event received after position_discontinuity
* if MediaItemTracker are using AnalyticsListener too
* They may received discontinuity for media item transition.
*/
override fun onMediaItemTransition(
eventTime: AnalyticsListener.EventTime,
mediaItem: MediaItem?,
@Player.MediaItemTransitionReason reason: Int,
) {
DebugLogger.debug(
TAG,
"onMediaItemTransition ${StringUtil.mediaItemTransitionReasonString(reason)} ${player.currentMediaItem?.prettyString()}"
)

if (mediaItem == null) {
stopSession(StopReason.Stop)
} else {
setMediaItem(mediaItem)
}
}
}

internal companion object {
private const val TAG = "AnalyticsMediaItemTracker"

private fun MediaItem.prettyString(): String {
return "$mediaId / ${localConfiguration?.uri} ${getMediaItemTrackerDataOrNull()}"
}

/**
* Are equals only checks mediaId and localConfiguration.uri
*
* @param m1
* @param m2
* @return
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun areEqual(m1: MediaItem?, m2: MediaItem?): Boolean {
return when {
m1 == null && m2 == null -> true
m1 == null || m2 == null -> false
else ->
m1.mediaId == m2.mediaId &&
m1.buildUpon().setTag(null).build().localConfiguration?.uri == m2.buildUpon().setTag(null).build().localConfiguration?.uri
}
}

private fun MediaItem.canHaveTrackingSession(): Boolean {
return this.getMediaItemTrackerDataOrNull() != null
}
}
}
Loading

0 comments on commit d9e96c2

Please sign in to comment.