From 24f037880df5111a11734404ffe9cbe0fb82fb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 21 Jun 2024 11:17:02 +0200 Subject: [PATCH 01/17] Add models for QoS and an `AnalyticsListener` to gather session load times (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joaquim Stähli --- pillarbox-player/build.gradle.kts | 6 +- .../pillarbox/player/PillarboxExoPlayer.kt | 12 ++ .../srgssr/pillarbox/player/qos/QoSError.kt | 42 +++++ .../srgssr/pillarbox/player/qos/QoSEvent.kt | 28 +++ .../srgssr/pillarbox/player/qos/QoSMessage.kt | 20 ++ .../srgssr/pillarbox/player/qos/QoSSession.kt | 121 ++++++++++++ .../player/qos/QoSSessionAnalyticsListener.kt | 92 +++++++++ .../pillarbox/player/qos/QoSSessionTimings.kt | 36 ++++ .../player/source/PillarboxMediaSource.kt | 59 ++++++ .../source/PillarboxMediaSourceFactory.kt | 14 +- .../pillarbox/player/qos/QoSErrorTest.kt | 56 ++++++ .../qos/QoSSessionAnalyticsListenerTest.kt | 100 ++++++++++ .../pillarbox/player/qos/QoSSessionTest.kt | 177 ++++++++++++++++++ .../player/qos/QoSSessionTimingsTest.kt | 33 ++++ 14 files changed, 793 insertions(+), 3 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt diff --git a/pillarbox-player/build.gradle.kts b/pillarbox-player/build.gradle.kts index 60f552010..afa0657d7 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -11,6 +11,10 @@ plugins { } android { + defaultConfig { + buildConfigField("String", "VERSION_NAME", "\"${version}\"") + } + buildFeatures { buildConfig = true } @@ -53,7 +57,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.mockk.dsl) - testRuntimeOnly(libs.robolectric) + testImplementation(libs.robolectric) testImplementation(libs.robolectric.annotations) testImplementation(libs.robolectric.shadows.framework) testImplementation(libs.turbine) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 1aace7e3b..7c5460b33 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player import android.content.Context +import android.util.Log import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -26,6 +27,8 @@ import ch.srgssr.pillarbox.player.asset.timeRange.Credit 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.qos.QoSSession +import ch.srgssr.pillarbox.player.qos.QoSSessionAnalyticsListener import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemPillarboxDataTracker @@ -37,6 +40,7 @@ import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger /** * Pillarbox player * + * @param context * @param exoPlayer * @param mediaItemTrackerProvider * @param analyticsCollector @@ -44,6 +48,7 @@ import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger * @constructor */ class PillarboxExoPlayer internal constructor( + context: Context, private val exoPlayer: ExoPlayer, mediaItemTrackerProvider: MediaItemTrackerProvider, analyticsCollector: PillarboxAnalyticsCollector, @@ -111,6 +116,7 @@ class PillarboxExoPlayer internal constructor( init { addListener(analyticsCollector) exoPlayer.addListener(ComponentListener()) + exoPlayer.addAnalyticsListener(QoSSessionAnalyticsListener(context, ::handleQoSSession)) itemPillarboxDataTracker.addCallback(timeRangeTracker) itemPillarboxDataTracker.addCallback(analyticsTracker) if (BuildConfig.DEBUG) { @@ -143,6 +149,7 @@ class PillarboxExoPlayer internal constructor( clock: Clock, analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), ) : this( + context, ExoPlayer.Builder(context) .setClock(clock) .setUsePlatformDiagnostics(false) @@ -342,6 +349,11 @@ class PillarboxExoPlayer internal constructor( playbackParameters = playbackParameters.withSpeed(speed) } + private fun handleQoSSession(qosSession: QoSSession) { + // TODO Do something with the session + Log.d("PillarboxExoPlayer", "[${qosSession.mediaId}] $qosSession") + } + private fun seekEnd() { isSeeking = false pendingSeek?.let { pendingPosition -> diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt new file mode 100644 index 000000000..1030b4e27 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +/** + * Represents a [Player][androidx.media3.common.Player] error to send to a QoS server. + * + * @property log The log associated with the error. + * @property message The error message. + * @property name The name of the error. + * @property playerPosition The position of the player when the error occurred, in milliseconds, or `null` if not available. + * @property severity The severity of the error, either [FATAL][Severity.FATAL] or [WARNING][Severity.WARNING]. + */ +data class QoSError( + val log: String, + val message: String, + val name: String, + val playerPosition: Long?, + val severity: Severity, +) { + /** + * Represents a [Player][androidx.media3.common.Player] error severity. + */ + enum class Severity { + FATAL, + WARNING, + } + + constructor( + throwable: Throwable, + playerPosition: Long?, + severity: Severity, + ) : this( + log = throwable.stackTraceToString(), + message = throwable.message.orEmpty(), + name = throwable::class.simpleName.orEmpty(), + playerPosition = playerPosition, + severity = severity, + ) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt new file mode 100644 index 000000000..7583136bf --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +/** + * Represents a generic event, which contains metrics about the current media stream. + * + * @property bandwidth The device-measured network bandwidth, in bytes per second. + * @property bitrate The bitrate of the current stream, in bytes per second. + * @property bufferDuration The forward duration of the buffer, in milliseconds. + * @property playbackDuration The duration of the playback, in milliseconds. + * @property playerPosition The position of the player, in milliseconds. + * @property stallCount The number of stalls that have occurred, not as a result of a seek. + * @property stallDuration The total duration of the stalls, in milliseconds. + * @property url The URL of the stream. + */ +data class QoSEvent( + val bandwidth: Long, + val bitrate: Long, + val bufferDuration: Long, + val playbackDuration: Long, + val playerPosition: Long, + val stallCount: Long, + val stallDuration: Long, + val url: String, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt new file mode 100644 index 000000000..92dd12605 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +/** + * Represents a QoS message. + * + * @property data The data associated with the message. + * @property eventName The name of the event. + * @property sessionId The session id. + * @property timestamp The current timestamp. + */ +data class QoSMessage( + val data: Any, + val eventName: String, + val sessionId: String, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt new file mode 100644 index 000000000..856d2d22b --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Rect +import android.os.Build +import android.view.WindowManager +import ch.srgssr.pillarbox.player.BuildConfig + +/** + * Represents a QoS session, which contains information about the device, current media, and player. + * + * @property deviceId The unique identifier of the device. + * @property deviceModel The model of the device. + * @property deviceType The type of device. + * @property mediaId The identifier of the media being played. + * @property mediaSource The source URL of the media being played. + * @property operatingSystemName The name of the operating system. + * @property operatingSystemVersion The version of the operating system. + * @property origin The origin of the player. + * @property playerName The name of the player. + * @property playerPlatform The platform of the player. + * @property playerVersion The version of the player. + * @property screenHeight The height of the screen in pixels. + * @property screenWidth The width of the screen in pixels. + * @property timings The timing until the current media started to play. + */ +data class QoSSession( + val deviceId: String, + val deviceModel: String = getDeviceModel(), + val deviceType: DeviceType, + val mediaId: String, + val mediaSource: String, + val operatingSystemName: String = PLATFORM_NAME, + val operatingSystemVersion: String = OPERATING_SYSTEM_VERSION, + val origin: String, + val playerName: String = PLAYER_NAME, + val playerPlatform: String = PLATFORM_NAME, + val playerVersion: String = PLAYER_VERSION, + val screenHeight: Int, + val screenWidth: Int, + val timings: QoSSessionTimings = QoSSessionTimings.Zero, +) { + /** + * The type of device. + */ + enum class DeviceType { + CAR, + PHONE, + TABLET, + TV, + } + + constructor( + context: Context, + mediaId: String, + mediaSource: String, + ) : this( + deviceId = getDeviceId(), + deviceType = context.getDeviceType(), + mediaId = mediaId, + mediaSource = mediaSource, + origin = context.packageName, + screenHeight = context.getWindowBounds().height(), + screenWidth = context.getWindowBounds().width(), + ) + + private companion object { + private val OPERATING_SYSTEM_VERSION = Build.VERSION.RELEASE + private const val PHONE_TABLET_WIDTH_THRESHOLD = 600 + private const val PLATFORM_NAME = "android" + private const val PLAYER_NAME = "pillarbox" + private const val PLAYER_VERSION = BuildConfig.VERSION_NAME + + @Suppress("FunctionOnlyReturningConstant") + private fun getDeviceId(): String { + // TODO Define this somehow (maybe use TCPredefinedVariables.getInstance().uniqueIdentifier) + return "" + } + + private fun getDeviceModel(): String { + return Build.MANUFACTURER + " " + Build.MODEL + } + + private fun Context.getDeviceType(): DeviceType { + val configuration = resources.configuration + return when (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) { + Configuration.UI_MODE_TYPE_CAR -> DeviceType.CAR + Configuration.UI_MODE_TYPE_NORMAL -> { + val smallestWidthDp = configuration.smallestScreenWidthDp + + if (smallestWidthDp >= PHONE_TABLET_WIDTH_THRESHOLD) { + DeviceType.TABLET + } else { + DeviceType.PHONE + } + } + + Configuration.UI_MODE_TYPE_TELEVISION -> DeviceType.TV + else -> DeviceType.PHONE // TODO Do we assume PHONE by default? Or do we throw an exception? + } + } + + private fun Context.getWindowBounds(): Rect { + val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + Rect().also { + @Suppress("DEPRECATION") + windowManager.defaultDisplay.getRectSize(it) + } + } else { + windowManager.maximumWindowMetrics.bounds + } + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt new file mode 100644 index 000000000..5276b97f2 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.content.Context +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.source.PillarboxMediaSource +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +internal class QoSSessionAnalyticsListener( + private val context: Context, + private val onQoSSessionReady: (qosSession: QoSSession) -> Unit, +) : AnalyticsListener { + private val loadingSessions = mutableSetOf() + private val qosSessions = mutableMapOf() + private val window = Timeline.Window() + + @Suppress("ReturnCount") + override fun onLoadCompleted( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + ) { + val mediaItem = getMediaItem(eventTime) ?: return + val sessionId = getSessionId(mediaItem) + + if (sessionId !in qosSessions) { + loadingSessions.add(sessionId) + qosSessions[sessionId] = createQoSSession(mediaItem) + } else if (sessionId !in loadingSessions) { + return + } + + val qosSession = qosSessions.getValue(sessionId) + val initialTimings = qosSession.timings + val loadDuration = loadEventInfo.loadDurationMs.milliseconds + + val timings = when (mediaLoadData.dataType) { + C.DATA_TYPE_DRM -> initialTimings.copy(drm = initialTimings.drm + loadDuration) + C.DATA_TYPE_MEDIA -> initialTimings.copy(mediaSource = initialTimings.mediaSource + loadDuration) + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> initialTimings.copy(asset = initialTimings.asset + loadDuration) + else -> return + } + + qosSessions[sessionId] = qosSession.copy(timings = timings) + } + + override fun onTracksChanged( + eventTime: AnalyticsListener.EventTime, + tracks: Tracks, + ) { + val mediaItem = getMediaItem(eventTime) ?: return + val sessionId = getSessionId(mediaItem) + + if (loadingSessions.remove(sessionId)) { + qosSessions[sessionId]?.let(onQoSSessionReady) + } + } + + private fun getSessionId(mediaItem: MediaItem): String { + val mediaId = mediaItem.mediaId + val mediaUrl = mediaItem.localConfiguration?.uri?.toString().orEmpty() + val name = (mediaId + mediaUrl).toByteArray() + + return UUID.nameUUIDFromBytes(name).toString() + } + + private fun getMediaItem(eventTime: AnalyticsListener.EventTime): MediaItem? { + return if (eventTime.timeline.isEmpty) { + null + } else { + eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem + } + } + + private fun createQoSSession(mediaItem: MediaItem): QoSSession { + return QoSSession( + context = context, + mediaId = mediaItem.mediaId, + mediaSource = mediaItem.localConfiguration?.uri?.toString().orEmpty(), + ) + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt new file mode 100644 index 000000000..5ce1ace30 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import kotlin.time.Duration + +/** + * Represents the timings until the current media started to play. + * + * @property asset The time spent to load the asset. + * @property drm The time spent to load the DRM. + * @property mediaSource The time spent to load the media source. + */ +data class QoSSessionTimings( + val asset: Duration, + val drm: Duration, + val mediaSource: Duration, +) { + /** + * The total time spent to load all the components. + */ + val total = asset + drm + mediaSource + + companion object { + /** + * Default [QoSSessionTimings] where all fields are a duration of zero. + */ + val Zero = QoSSessionTimings( + asset = Duration.ZERO, + drm = Duration.ZERO, + mediaSource = Duration.ZERO, + ) + } +} 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 c37468765..b8d4a85c8 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 @@ -4,11 +4,15 @@ */ package ch.srgssr.pillarbox.player.source +import android.net.Uri +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Timeline +import androidx.media3.datasource.DataSpec import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.source.CompositeMediaSource import androidx.media3.exoplayer.source.ForwardingTimeline +import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem @@ -17,6 +21,9 @@ import ch.srgssr.pillarbox.player.asset.AssetLoader import ch.srgssr.pillarbox.player.asset.PillarboxData import ch.srgssr.pillarbox.player.utils.DebugLogger import kotlinx.coroutines.runBlocking +import java.io.IOException +import kotlin.time.TimeMark +import kotlin.time.TimeSource /** * Pillarbox media source @@ -24,18 +31,24 @@ import kotlinx.coroutines.runBlocking * @param mediaItem The [MediaItem] to used for the assetLoader. * @param assetLoader The [AssetLoader] to used to load the source. * @param minLiveDvrDurationMs Minimal duration in milliseconds to consider a live with seek capabilities. + * @param timeSource The [TimeSource]. * @constructor Create empty Pillarbox media source */ class PillarboxMediaSource internal constructor( private var mediaItem: MediaItem, private val assetLoader: AssetLoader, private val minLiveDvrDurationMs: Long, + private val timeSource: TimeSource = TimeSource.Monotonic, ) : CompositeMediaSource() { private lateinit var mediaSource: MediaSource private var pendingError: Throwable? = null + private val eventDispatcher by lazy { createEventDispatcher(null) } + private var loadTaskId = 0L + private var timeMarkLoadStart: TimeMark? = null @Suppress("TooGenericExceptionCaught") override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { + dispatchLoadStarted() super.prepareSourceInternal(mediaTransferListener) DebugLogger.debug(TAG, "prepareSourceInternal: mediaId = ${mediaItem.mediaId} on ${Thread.currentThread()}") pendingError = null @@ -43,6 +56,7 @@ class PillarboxMediaSource internal constructor( runBlocking { try { val asset = assetLoader.loadAsset(mediaItem) + dispatchLoadCompleted() DebugLogger.debug(TAG, "Asset(${mediaItem.localConfiguration?.uri}) : ${asset.trackersData}") mediaSource = asset.mediaSource mediaItem = mediaItem.buildUpon() @@ -131,6 +145,7 @@ class PillarboxMediaSource internal constructor( private fun handleException(exception: Throwable) { DebugLogger.error(TAG, "error while preparing source", exception) + dispatchLoadError(exception) pendingError = exception } @@ -149,7 +164,51 @@ class PillarboxMediaSource internal constructor( } } + private fun dispatchLoadStarted() { + loadTaskId = LoadEventInfo.getNewId() + timeMarkLoadStart = timeSource.markNow() + + eventDispatcher.loadStarted(createLoadEventInfo(), DATA_TYPE_CUSTOM_ASSET) + } + + private fun dispatchLoadCompleted() { + val startTimeMark = timeMarkLoadStart ?: return + + eventDispatcher.loadCompleted(createLoadEventInfo(startTimeMark), DATA_TYPE_CUSTOM_ASSET) + + loadTaskId = 0L + timeMarkLoadStart = null + } + + private fun dispatchLoadError(exception: Throwable) { + val startTimeMark = timeMarkLoadStart ?: return + + eventDispatcher.loadError(createLoadEventInfo(startTimeMark = startTimeMark), DATA_TYPE_CUSTOM_ASSET, IOException(exception), false) + + loadTaskId = 0L + timeMarkLoadStart = null + } + + private fun createLoadEventInfo(startTimeMark: TimeMark? = null): LoadEventInfo { + val currentTimeMark = timeSource.markNow() + val mediaUri = mediaItem.localConfiguration?.uri ?: Uri.EMPTY + + return LoadEventInfo( + loadTaskId, + DataSpec(mediaUri), + mediaUri, + emptyMap(), + currentTimeMark.elapsedNow().inWholeMilliseconds, + startTimeMark?.let { (it.elapsedNow() - currentTimeMark.elapsedNow()).inWholeMilliseconds } ?: 0L, + 0L, + ) + } + companion object { + /** + * Data type for SRG SSR assets. + */ + const val DATA_TYPE_CUSTOM_ASSET = C.DATA_TYPE_CUSTOM_BASE + 1 private const val TAG = "PillarboxMediaSource" } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt index f34b46d83..962246c8a 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt @@ -12,14 +12,19 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import ch.srgssr.pillarbox.player.asset.AssetLoader import ch.srgssr.pillarbox.player.asset.UrlAssetLoader +import kotlin.time.TimeSource /** * Pillarbox media source factory create a new [PillarboxMediaSource] from a [MediaItem]. * It selects the first [AssetLoader] to use by checking if [AssetLoader.canLoadAsset]. * * @param context to create the [defaultAssetLoader]. + * @param timeSource The [TimeSource]. */ -class PillarboxMediaSourceFactory(context: Context) : MediaSource.Factory { +class PillarboxMediaSourceFactory( + context: Context, + private val timeSource: TimeSource = TimeSource.Monotonic +) : MediaSource.Factory { /** * Default asset loader used when no other AssetLoader has been found. */ @@ -75,7 +80,12 @@ class PillarboxMediaSourceFactory(context: Context) : MediaSource.Factory { override fun createMediaSource(mediaItem: MediaItem): MediaSource { checkNotNull(mediaItem.localConfiguration) val assetLoader = listAssetLoader.firstOrNull { it.canLoadAsset(mediaItem) } ?: defaultAssetLoader - return PillarboxMediaSource(mediaItem = mediaItem, assetLoader = assetLoader, minLiveDvrDurationMs = minLiveDvrDurationMs) + return PillarboxMediaSource( + mediaItem = mediaItem, + assetLoader = assetLoader, + minLiveDvrDurationMs = minLiveDvrDurationMs, + timeSource = timeSource + ) } companion object { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt new file mode 100644 index 000000000..f99a7aaed --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import java.lang.RuntimeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class QoSErrorTest { + @Test + fun `throwableConstructor with empty exception`() { + val throwable = IllegalStateException() + val qosError = QoSError( + throwable = throwable, + playerPosition = 5.minutes.inWholeMilliseconds, + severity = QoSError.Severity.WARNING, + ) + + val logLines = qosError.log.lineSequence() + + assertTrue(logLines.count() > 1, "Expected log to contain the stacktrace") + assertEquals("java.lang.IllegalStateException", logLines.first()) + assertTrue(logLines.none { it.startsWith("Caused by: ") }, "Expected log to not contain a cause") + assertEquals("", qosError.message) + assertEquals("IllegalStateException", qosError.name) + assertEquals(QoSError.Severity.WARNING, qosError.severity) + } + + @Test + fun `throwableConstructor with detailed exception`() { + val cause = NullPointerException("Expected 'foo' to be not null") + val throwable = RuntimeException("Something bad happened", cause) + val qosError = QoSError( + throwable = throwable, + playerPosition = 30.seconds.inWholeMilliseconds, + severity = QoSError.Severity.FATAL, + ) + + val logLines = qosError.log.lineSequence() + + assertTrue(logLines.count() > 1, "Expected log to contain the stacktrace") + assertEquals("java.lang.RuntimeException: ${throwable.message}", logLines.first()) + assertTrue( + logLines.any { it == "Caused by: java.lang.NullPointerException: ${cause.message}" }, + "Expected log to contain a cause", + ) + assertEquals(throwable.message, qosError.message) + assertEquals("RuntimeException", qosError.name) + assertEquals(QoSError.Severity.FATAL, qosError.severity) + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt new file mode 100644 index 000000000..acf2af49c --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.content.Context +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters +import org.robolectric.Shadows.shadowOf +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +@RunWith(ParameterizedRobolectricTestRunner::class) +class QoSSessionAnalyticsListenerTest( + private val mediaUrls: List, +) { + private lateinit var player: Player + private val qosSessions = mutableListOf() + + @BeforeTest + fun setUp() { + player = createPlayer(mediaUrls) { + qosSessions.add(it) + } + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + mediaUrls.forEachIndexed { index, _ -> + player.seekTo(5.seconds.inWholeMilliseconds) + player.seekToNextMediaItem() + } + } + + @Test + fun `qos session analytics listener`() { + assertEquals(mediaUrls, qosSessions.map { it.mediaSource }) + } + + @AfterTest + fun tearDown() { + player.release() + qosSessions.clear() + + shadowOf(Looper.getMainLooper()).idle() + } + + private fun createPlayer( + mediaUrls: List, + callback: (qosSession: QoSSession) -> Unit, + ): Player { + val context = ApplicationProvider.getApplicationContext() + val listener = QoSSessionAnalyticsListener(context, callback) + + return PillarboxExoPlayer( + context = context, + clock = FakeClock(true), + ).apply { + val mediaItems = mediaUrls.map(MediaItem::fromUri) + + addMediaItems(mediaItems) + addAnalyticsListener(listener) + prepare() + play() + } + } + + companion object { + @JvmStatic + @Parameters(name = "{index}: {0}") + fun parameters(): Iterable { + return listOf( + // AOD only + listOf("https://rts-aod-dd.akamaized.net/ww/14965091/0ba47e81-57d5-3bc0-b3e5-18c93cc84da3.mp3"), + // VOD only + listOf("https://rts-vod-amd.akamaized.net/ww/14981648/85b72399-a98c-3455-bdad-70c82cdf0a30/master.m3u8"), + // Live DVR audio only + listOf("https://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8"), + // Live DVR video only + listOf("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8"), + // Playlist with mixed content + listOf( + "https://rts-aod-dd.akamaized.net/ww/14967482/effca0a1-d59d-3b64-b7eb-cc58a4ad75d6.mp3", + "https://rts-vod-amd.akamaized.net/ww/14983127/c9a205b7-0b47-35ac-989a-9306883470bf/master.m3u8", + ), + ) + } + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt new file mode 100644 index 000000000..25eb40d5b --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class QoSSessionTest { + @Test + fun `contextConstructor provides correct default values`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(sdk = [30]) + fun `contextConstructor provides correct default values (API 30)`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("robolectric robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("11", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "car") + fun `contextConstructor provides correct default values for car`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.CAR, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "sw320dp") + fun `contextConstructor provides correct default values for phone (sw320dp)`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "sw600dp") + fun `contextConstructor provides correct default values for tablet (sw600dp)`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.TABLET, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "sw720dp") + fun `contextConstructor provides correct default values for tablet (sw720dp)`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.TABLET, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "television") + fun `contextConstructor provides correct default values for TV`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.TV, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + @Test + @Config(qualifiers = "watch") + fun `contextConstructor provides correct default values for watch`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.deviceId) + assertEquals("unknown robolectric", qosSession.deviceModel) + assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) + assertEquals("android", qosSession.operatingSystemName) + assertEquals("5.0.2", qosSession.operatingSystemVersion) + assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) + assertEquals("pillarbox", qosSession.playerName) + assertEquals("android", qosSession.playerPlatform) + assertEquals("Local", qosSession.playerVersion) + assertEquals(470, qosSession.screenHeight) + assertEquals(320, qosSession.screenWidth) + assertEquals(QoSSessionTimings.Zero, qosSession.timings) + } + + private fun createQoSSession(): QoSSession { + val context = ApplicationProvider.getApplicationContext() + + return QoSSession( + context = context, + mediaId = "urn:rts:video:12345", + mediaSource = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY", + ) + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt new file mode 100644 index 000000000..ae4e19e87 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class QoSSessionTimingsTest { + @Test + fun `zero timings`() { + val timings = QoSSessionTimings.Zero + + assertEquals(Duration.ZERO, timings.asset) + assertEquals(Duration.ZERO, timings.drm) + assertEquals(Duration.ZERO, timings.mediaSource) + assertEquals(Duration.ZERO, timings.total) + } + + @Test + fun `timings compute total value`() { + val timings = QoSSessionTimings( + asset = 250.milliseconds, + drm = 33.milliseconds, + mediaSource = 100.milliseconds, + ) + + assertEquals(383.milliseconds, timings.total) + } +} From 2f2618dfe5fd6435ce9bc9cb6f44dda2c3ade783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Mon, 24 Jun 2024 09:04:04 +0200 Subject: [PATCH 02/17] 592 playback session manager (#597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller --- .../pillarbox/player/PillarboxExoPlayer.kt | 23 ++ .../analytics/PlaybackSessionManager.kt | 205 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 7c5460b33..e67c3b48c 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -21,6 +21,7 @@ import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter import ch.srgssr.pillarbox.player.asset.timeRange.Credit @@ -114,6 +115,28 @@ class PillarboxExoPlayer internal constructor( ) init { + addAnalyticsListener( + PlaybackSessionManager().apply { + this.listener = object : PlaybackSessionManager.Listener { + private val TAG = "SessionManager" + private fun PlaybackSessionManager.Session.prettyString(): String { + return "$sessionId / ${mediaItem.mediaMetadata.title}" + } + + override fun onSessionCreated(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onSessionCreated ${session.prettyString()}") + } + + override fun onSessionFinished(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onSessionFinished ${session.prettyString()}") + } + + override fun onCurrentSession(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onCurrentSession ${session.prettyString()}") + } + } + } + ) addListener(analyticsCollector) exoPlayer.addListener(ComponentListener()) exoPlayer.addAnalyticsListener(QoSSessionAnalyticsListener(context, ::handleQoSSession)) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt new file mode 100644 index 000000000..872d727a9 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.Timeline.Window +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.utils.DebugLogger +import ch.srgssr.pillarbox.player.utils.StringUtil +import java.util.UUID + +/** + * Playback session manager + * + * - Session is created when the player does something with a [MediaItem]. + * - Session is current if the media item associated with session is the current [MediaItem]. + * - Session is finished when it is no longer the current session or when the session is removed from the player. + */ +class PlaybackSessionManager : AnalyticsListener { + /** + * Listener + */ + interface Listener { + /** + * On session created + * + * @param session + */ + fun onSessionCreated(session: Session) + + /** + * On session finished + * + * @param session + */ + fun onSessionFinished(session: Session) + + /** + * On current session + * + * @param session + */ + fun onCurrentSession(session: Session) + } + + /** + * Session + * + * @property mediaItem The [MediaItem] linked to the session. + */ + class Session(val mediaItem: MediaItem) { + /** + * Unique Session Id + */ + val sessionId = UUID.randomUUID().toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Session + + return sessionId == other.sessionId + } + + override fun hashCode(): Int { + return sessionId.hashCode() + } + } + + private val sessions = HashMap() + private val window = Window() + + /** + * Listener + */ + var listener: Listener? = null + + /** + * Current session + */ + var currentSession: Session? = null + private set(value) { + if (field != value) { + field?.let { + listener?.onSessionFinished(it) + sessions.remove(it.sessionId) + } + field = value + field?.let { + listener?.onCurrentSession(it) + } + } + } + + /** + * Get or create a session from a [MediaItem]. + * + * @param mediaItem The [MediaItem]. + * @return A [Session] associated with `mediaItem`. + */ + fun getOrCreateSession(mediaItem: MediaItem): Session { + val session = sessions.values.firstOrNull { it.mediaItem.isTheSame(mediaItem) } + if (session == null) { + val newSession = Session(mediaItem) + sessions[newSession.sessionId] = newSession + listener?.onSessionCreated(newSession) + if (currentSession == null) { + currentSession = newSession + } + return newSession + } + return session + } + + override fun onPositionDiscontinuity( + eventTime: EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + val oldItemIndex = oldPosition.mediaItemIndex + val newItemIndex = newPosition.mediaItemIndex + DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") + if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { + val newSession = getOrCreateSession(eventTime.timeline.getWindow(newItemIndex, window).mediaItem) + currentSession = newSession + } + } + + override fun onMediaItemTransition(eventTime: EventTime, mediaItem: MediaItem?, reason: Int) { + DebugLogger.debug( + TAG, + "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}" + ) + currentSession = mediaItem?.let { getOrCreateSession(it) } + } + + override fun onTimelineChanged(eventTime: EventTime, reason: Int) { + DebugLogger.debug(TAG, "onTimelineChanged ${StringUtil.timelineChangeReasonString(reason)} ${eventTime.getMediaItem().mediaMetadata.title}") + if (eventTime.timeline.isEmpty) { + finishAllSession() + return + } + val timeline = eventTime.timeline + val listNewItems = ArrayList() + for (i in 0 until timeline.windowCount) { + val mediaItem = timeline.getWindow(i, window).mediaItem + listNewItems.add(mediaItem) + } + val sessions = HashSet(sessions.values) + for (session in sessions) { + val matchingItem = listNewItems.firstOrNull { it.isTheSame(session.mediaItem) } + if (matchingItem == null) { + if (session == currentSession) currentSession = null + else { + listener?.onSessionFinished(session) + this.sessions.remove(session.sessionId) + } + } + } + } + + override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + if (eventTime.timeline.isEmpty) return + val mediaItem = eventTime.getMediaItem() + if (mediaItem != MediaItem.EMPTY) { + getOrCreateSession(mediaItem) + } + } + + override fun onPlayerReleased(eventTime: EventTime) { + DebugLogger.debug(TAG, "onPlayerReleased") + finishAllSession() + } + + private fun finishAllSession() { + currentSession = null + for (session in sessions.values) { + listener?.onSessionFinished(session) + } + sessions.clear() + } + + companion object { + + private const val TAG = "SessionManager" + + private fun EventTime.getMediaItem(): MediaItem { + if (timeline.isEmpty) return MediaItem.EMPTY + return timeline.getWindow(windowIndex, Timeline.Window()).mediaItem + } + + private fun MediaItem.isTheSame(mediaItem: MediaItem): Boolean { + return mediaId == mediaItem.mediaId && localConfiguration?.uri == mediaItem.localConfiguration?.uri + } + } +} From cf04c2ddbc85ad6bc3eca79038a86535b56d5825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 27 Jun 2024 09:36:36 +0200 Subject: [PATCH 03/17] Link `PlaybackSessionManager` with `QoSSessionAnalyticsListener` (#609) --- .../pillarbox/player/PillarboxExoPlayer.kt | 40 ++-- .../analytics/PlaybackSessionManager.kt | 28 ++- .../player/qos/QoSSessionAnalyticsListener.kt | 89 +++++--- .../pillarbox/player/qos/QoSSessionTimings.kt | 9 +- .../analytics/PlaybackSessionManagerTest.kt | 205 ++++++++++++++++++ .../qos/QoSSessionAnalyticsListenerTest.kt | 30 ++- .../player/qos/QoSSessionTimingsTest.kt | 14 +- 7 files changed, 329 insertions(+), 86 deletions(-) create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index e67c3b48c..8e5f30fc4 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -115,31 +115,33 @@ class PillarboxExoPlayer internal constructor( ) init { - addAnalyticsListener( - PlaybackSessionManager().apply { - this.listener = object : PlaybackSessionManager.Listener { - private val TAG = "SessionManager" - private fun PlaybackSessionManager.Session.prettyString(): String { - return "$sessionId / ${mediaItem.mediaMetadata.title}" - } + val qoSSessionAnalyticsListener = QoSSessionAnalyticsListener(context, ::handleQoSSession) + val sessionManagerListener = object : PlaybackSessionManager.Listener { + private val TAG = "SessionManager" + private fun PlaybackSessionManager.Session.prettyString(): String { + return "$sessionId / ${mediaItem.mediaMetadata.title}" + } - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onSessionCreated ${session.prettyString()}") - } + override fun onSessionCreated(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onSessionCreated ${session.prettyString()}") + qoSSessionAnalyticsListener.onSessionCreated(session) + } - override fun onSessionFinished(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onSessionFinished ${session.prettyString()}") - } + override fun onSessionFinished(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onSessionFinished ${session.prettyString()}") + qoSSessionAnalyticsListener.onSessionFinished(session) + } - override fun onCurrentSession(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onCurrentSession ${session.prettyString()}") - } - } + override fun onCurrentSession(session: PlaybackSessionManager.Session) { + Log.i(TAG, "onCurrentSession ${session.prettyString()}") + qoSSessionAnalyticsListener.onCurrentSession(session) } - ) + } + + addAnalyticsListener(PlaybackSessionManager(sessionManagerListener)) addListener(analyticsCollector) exoPlayer.addListener(ComponentListener()) - exoPlayer.addAnalyticsListener(QoSSessionAnalyticsListener(context, ::handleQoSSession)) + exoPlayer.addAnalyticsListener(qoSSessionAnalyticsListener) itemPillarboxDataTracker.addCallback(timeRangeTracker) itemPillarboxDataTracker.addCallback(analyticsTracker) if (BuildConfig.DEBUG) { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index 872d727a9..bfbdbc628 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -6,7 +6,7 @@ package ch.srgssr.pillarbox.player.analytics import androidx.media3.common.MediaItem import androidx.media3.common.Player -import androidx.media3.common.Timeline +import androidx.media3.common.Player.TimelineChangeReason import androidx.media3.common.Timeline.Window import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime @@ -22,8 +22,12 @@ import java.util.UUID * - Session is created when the player does something with a [MediaItem]. * - Session is current if the media item associated with session is the current [MediaItem]. * - Session is finished when it is no longer the current session or when the session is removed from the player. + * + * @param listener The listener attached to the session manager. */ -class PlaybackSessionManager : AnalyticsListener { +class PlaybackSessionManager( + private val listener: Listener, +) : AnalyticsListener { /** * Listener */ @@ -78,11 +82,6 @@ class PlaybackSessionManager : AnalyticsListener { private val sessions = HashMap() private val window = Window() - /** - * Listener - */ - var listener: Listener? = null - /** * Current session */ @@ -90,12 +89,12 @@ class PlaybackSessionManager : AnalyticsListener { private set(value) { if (field != value) { field?.let { - listener?.onSessionFinished(it) + listener.onSessionFinished(it) sessions.remove(it.sessionId) } field = value field?.let { - listener?.onCurrentSession(it) + listener.onCurrentSession(it) } } } @@ -111,7 +110,7 @@ class PlaybackSessionManager : AnalyticsListener { if (session == null) { val newSession = Session(mediaItem) sessions[newSession.sessionId] = newSession - listener?.onSessionCreated(newSession) + listener.onSessionCreated(newSession) if (currentSession == null) { currentSession = newSession } @@ -143,7 +142,7 @@ class PlaybackSessionManager : AnalyticsListener { currentSession = mediaItem?.let { getOrCreateSession(it) } } - override fun onTimelineChanged(eventTime: EventTime, reason: Int) { + override fun onTimelineChanged(eventTime: EventTime, @TimelineChangeReason reason: Int) { DebugLogger.debug(TAG, "onTimelineChanged ${StringUtil.timelineChangeReasonString(reason)} ${eventTime.getMediaItem().mediaMetadata.title}") if (eventTime.timeline.isEmpty) { finishAllSession() @@ -161,7 +160,7 @@ class PlaybackSessionManager : AnalyticsListener { if (matchingItem == null) { if (session == currentSession) currentSession = null else { - listener?.onSessionFinished(session) + listener.onSessionFinished(session) this.sessions.remove(session.sessionId) } } @@ -169,7 +168,6 @@ class PlaybackSessionManager : AnalyticsListener { } override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { - if (eventTime.timeline.isEmpty) return val mediaItem = eventTime.getMediaItem() if (mediaItem != MediaItem.EMPTY) { getOrCreateSession(mediaItem) @@ -184,7 +182,7 @@ class PlaybackSessionManager : AnalyticsListener { private fun finishAllSession() { currentSession = null for (session in sessions.values) { - listener?.onSessionFinished(session) + listener.onSessionFinished(session) } sessions.clear() } @@ -195,7 +193,7 @@ class PlaybackSessionManager : AnalyticsListener { private fun EventTime.getMediaItem(): MediaItem { if (timeline.isEmpty) return MediaItem.EMPTY - return timeline.getWindow(windowIndex, Timeline.Window()).mediaItem + return timeline.getWindow(windowIndex, Window()).mediaItem } private fun MediaItem.isTheSame(mediaItem: MediaItem): Boolean { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt index 5276b97f2..e496bd512 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt @@ -8,12 +8,11 @@ import android.content.Context import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Timeline -import androidx.media3.common.Tracks import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.source.PillarboxMediaSource -import java.util.UUID import kotlin.time.Duration.Companion.milliseconds internal class QoSSessionAnalyticsListener( @@ -21,22 +20,39 @@ internal class QoSSessionAnalyticsListener( private val onQoSSessionReady: (qosSession: QoSSession) -> Unit, ) : AnalyticsListener { private val loadingSessions = mutableSetOf() + private val mediaIdToSessionId = mutableMapOf() + private val currentSessionToMediaStart = mutableMapOf() private val qosSessions = mutableMapOf() private val window = Timeline.Window() - @Suppress("ReturnCount") + fun onSessionCreated(session: PlaybackSessionManager.Session) { + loadingSessions.add(session.sessionId) + mediaIdToSessionId[session.mediaItem.mediaId] = session.sessionId + qosSessions[session.sessionId] = QoSSession( + context = context, + mediaId = session.mediaItem.mediaId, + mediaSource = session.mediaItem.localConfiguration?.uri?.toString().orEmpty(), + ) + } + + fun onCurrentSession(session: PlaybackSessionManager.Session) { + currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() + } + + fun onSessionFinished(session: PlaybackSessionManager.Session) { + loadingSessions.remove(session.sessionId) + mediaIdToSessionId.remove(session.mediaItem.mediaId) + qosSessions.remove(session.sessionId) + } + override fun onLoadCompleted( eventTime: AnalyticsListener.EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData, ) { - val mediaItem = getMediaItem(eventTime) ?: return - val sessionId = getSessionId(mediaItem) - - if (sessionId !in qosSessions) { - loadingSessions.add(sessionId) - qosSessions[sessionId] = createQoSSession(mediaItem) - } else if (sessionId !in loadingSessions) { + val mediaItem = getMediaItem(eventTime) + val sessionId = mediaIdToSessionId[mediaItem?.mediaId] + if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessions) { return } @@ -46,32 +62,49 @@ internal class QoSSessionAnalyticsListener( val timings = when (mediaLoadData.dataType) { C.DATA_TYPE_DRM -> initialTimings.copy(drm = initialTimings.drm + loadDuration) - C.DATA_TYPE_MEDIA -> initialTimings.copy(mediaSource = initialTimings.mediaSource + loadDuration) + C.DATA_TYPE_MANIFEST, C.DATA_TYPE_MEDIA -> initialTimings.copy(mediaSource = initialTimings.mediaSource + loadDuration) PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> initialTimings.copy(asset = initialTimings.asset + loadDuration) - else -> return + else -> initialTimings } qosSessions[sessionId] = qosSession.copy(timings = timings) } - override fun onTracksChanged( + override fun onAudioPositionAdvancing( eventTime: AnalyticsListener.EventTime, - tracks: Tracks, + playoutStartSystemTimeMs: Long, ) { - val mediaItem = getMediaItem(eventTime) ?: return - val sessionId = getSessionId(mediaItem) + notifyQoSSessionReady(eventTime) + } - if (loadingSessions.remove(sessionId)) { - qosSessions[sessionId]?.let(onQoSSessionReady) - } + override fun onRenderedFirstFrame( + eventTime: AnalyticsListener.EventTime, + output: Any, + renderTimeMs: Long, + ) { + notifyQoSSessionReady(eventTime) } - private fun getSessionId(mediaItem: MediaItem): String { - val mediaId = mediaItem.mediaId - val mediaUrl = mediaItem.localConfiguration?.uri?.toString().orEmpty() - val name = (mediaId + mediaUrl).toByteArray() + private fun notifyQoSSessionReady(eventTime: AnalyticsListener.EventTime) { + val mediaItem = getMediaItem(eventTime) + val sessionId = mediaIdToSessionId[mediaItem?.mediaId] ?: return + + if (loadingSessions.remove(sessionId)) { + qosSessions[sessionId]?.let { + val qosSession = if (sessionId in currentSessionToMediaStart) { + it.copy( + timings = it.timings.copy( + currentToStart = (System.currentTimeMillis() - currentSessionToMediaStart.getValue(sessionId)).milliseconds, + ), + ) + } else { + it + } - return UUID.nameUUIDFromBytes(name).toString() + currentSessionToMediaStart.remove(sessionId) + onQoSSessionReady(qosSession) + } + } } private fun getMediaItem(eventTime: AnalyticsListener.EventTime): MediaItem? { @@ -81,12 +114,4 @@ internal class QoSSessionAnalyticsListener( eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem } } - - private fun createQoSSession(mediaItem: MediaItem): QoSSession { - return QoSSession( - context = context, - mediaId = mediaItem.mediaId, - mediaSource = mediaItem.localConfiguration?.uri?.toString().orEmpty(), - ) - } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt index 5ce1ace30..6db8bf8b1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt @@ -10,25 +10,24 @@ import kotlin.time.Duration * Represents the timings until the current media started to play. * * @property asset The time spent to load the asset. + * @property currentToStart The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until it + * started to play. * @property drm The time spent to load the DRM. * @property mediaSource The time spent to load the media source. */ data class QoSSessionTimings( val asset: Duration, + val currentToStart: Duration, val drm: Duration, val mediaSource: Duration, ) { - /** - * The total time spent to load all the components. - */ - val total = asset + drm + mediaSource - companion object { /** * Default [QoSSessionTimings] where all fields are a duration of zero. */ val Zero = QoSSessionTimings( asset = Duration.ZERO, + currentToStart = Duration.ZERO, drm = Duration.ZERO, mediaSource = Duration.ZERO, ) diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt new file mode 100644 index 000000000..52ab7500e --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +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.test.utils.TestPillarboxRunHelper +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verifyOrder +import org.junit.runner.RunWith +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class PlaybackSessionManagerTest { + private lateinit var clock: FakeClock + private lateinit var player: Player + private lateinit var sessionManagerListener: PlaybackSessionManager.Listener + + @BeforeTest + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + + clock = FakeClock(true) + sessionManagerListener = mockk(relaxed = true) + player = ExoPlayer.Builder(context) + .setClock(clock) + .build() + .apply { + addAnalyticsListener(PlaybackSessionManager(sessionManagerListener)) + prepare() + } + } + + @Test + fun `play single media item`() { + val mediaItem = MediaItem.fromUri(VOD1) + + player.setMediaItem(mediaItem) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) + sessionManagerListener.onCurrentSession(capture(sessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(2, sessions.size) + assertEquals(1, sessions.distinctBy { it.sessionId }.size) + assertTrue(sessions.all { it.mediaItem == mediaItem }) + } + + @Test + fun `play single media item, remove media item`() { + val mediaItem = MediaItem.fromUri(VOD1) + + player.setMediaItem(mediaItem) + player.play() + + TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) + + player.removeMediaItem(0) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) + sessionManagerListener.onCurrentSession(capture(sessions)) + sessionManagerListener.onSessionFinished(capture(sessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, sessions.size) + assertEquals(1, sessions.distinctBy { it.sessionId }.size) + assertTrue(sessions.all { it.mediaItem == mediaItem }) + } + + @Test + fun `play multiple media items`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 2 + sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 2 + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 + sessionManagerListener.onSessionFinished(capture(sessions)) // Item 2 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + } + confirmVerified(sessionManagerListener) + + assertEquals(8, sessions.size) + assertEquals(3, sessions.distinctBy { it.sessionId }.size) + assertEquals( + listOf(mediaItems[0], mediaItems[0], mediaItems[1], mediaItems[0], mediaItems[1], mediaItems[2], mediaItems[1], mediaItems[2]), + sessions.map { it.mediaItem }.reversed(), + ) + } + + @Test + fun `play multiple media items, remove upcoming media item`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) + + player.removeMediaItem(1) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 + sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + } + confirmVerified(sessionManagerListener) + + assertEquals(5, sessions.size) + assertEquals(2, sessions.distinctBy { it.sessionId }.size) + assertEquals( + listOf(mediaItems[0], mediaItems[0], mediaItems[2], mediaItems[0], mediaItems[2]), + sessions.map { it.mediaItem }.reversed(), + ) + } + + @Test + fun `play multiple media items, remove current media item`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) + + player.removeMediaItem(0) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 + sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 2 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 2 + sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 + sessionManagerListener.onSessionFinished(capture(sessions)) // Item 2 + sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + } + confirmVerified(sessionManagerListener) + + assertEquals(8, sessions.size) + assertEquals(3, sessions.distinctBy { it.sessionId }.size) + assertEquals( + listOf(mediaItems[0], mediaItems[0], mediaItems[0], mediaItems[1], mediaItems[1], mediaItems[2], mediaItems[1], mediaItems[2]), + sessions.map { it.mediaItem }.reversed(), + ) + } + + @AfterTest + fun tearDown() { + player.release() + } + + private companion object { + private const val VOD1 = "https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8" + private const val VOD2 = "https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8" + private const val VOD3 = "https://rts-vod-amd.akamaized.net/ww/13444466/2787e520-412f-35fb-83d7-8dbb31b5c684/master.m3u8" + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt index acf2af49c..d60d59407 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt @@ -6,18 +6,22 @@ package ch.srgssr.pillarbox.player.qos import android.content.Context import android.os.Looper +import android.view.SurfaceView +import android.view.ViewGroup import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.test.utils.FakeClock import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner.Parameters import org.robolectric.Shadows.shadowOf import kotlin.test.AfterTest import kotlin.test.BeforeTest +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -35,6 +39,12 @@ class QoSSessionAnalyticsListenerTest( qosSessions.add(it) } + // Attach the Player to a surface + val surfaceView = SurfaceView(ApplicationProvider.getApplicationContext()) + surfaceView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + + player.setVideoSurfaceView(surfaceView) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) mediaUrls.forEachIndexed { index, _ -> @@ -44,6 +54,7 @@ class QoSSessionAnalyticsListenerTest( } @Test + @Ignore("SurfaceView/SurfaceHolder not implemented in Robolectric") fun `qos session analytics listener`() { assertEquals(mediaUrls, qosSessions.map { it.mediaSource }) } @@ -61,7 +72,21 @@ class QoSSessionAnalyticsListenerTest( callback: (qosSession: QoSSession) -> Unit, ): Player { val context = ApplicationProvider.getApplicationContext() - val listener = QoSSessionAnalyticsListener(context, callback) + val qosSessionAnalyticsListener = QoSSessionAnalyticsListener(context, callback) + val playbackSessionManagerListener = object : PlaybackSessionManager.Listener { + override fun onSessionCreated(session: PlaybackSessionManager.Session) { + qosSessionAnalyticsListener.onSessionCreated(session) + } + + override fun onCurrentSession(session: PlaybackSessionManager.Session) { + qosSessionAnalyticsListener.onCurrentSession(session) + } + + override fun onSessionFinished(session: PlaybackSessionManager.Session) { + qosSessionAnalyticsListener.onSessionFinished(session) + } + } + val playbackSessionManager = PlaybackSessionManager(playbackSessionManagerListener) return PillarboxExoPlayer( context = context, @@ -70,7 +95,8 @@ class QoSSessionAnalyticsListenerTest( val mediaItems = mediaUrls.map(MediaItem::fromUri) addMediaItems(mediaItems) - addAnalyticsListener(listener) + addAnalyticsListener(qosSessionAnalyticsListener) + addAnalyticsListener(playbackSessionManager) prepare() play() } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt index ae4e19e87..4925dbe4c 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt @@ -7,7 +7,6 @@ package ch.srgssr.pillarbox.player.qos import kotlin.test.Test import kotlin.test.assertEquals import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds class QoSSessionTimingsTest { @Test @@ -15,19 +14,8 @@ class QoSSessionTimingsTest { val timings = QoSSessionTimings.Zero assertEquals(Duration.ZERO, timings.asset) + assertEquals(Duration.ZERO, timings.currentToStart) assertEquals(Duration.ZERO, timings.drm) assertEquals(Duration.ZERO, timings.mediaSource) - assertEquals(Duration.ZERO, timings.total) - } - - @Test - fun `timings compute total value`() { - val timings = QoSSessionTimings( - asset = 250.milliseconds, - drm = 33.milliseconds, - mediaSource = 100.milliseconds, - ) - - assertEquals(383.milliseconds, timings.total) } } From b8be3f7ccf4d04556988230a5ba5364c4a24c630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Fri, 28 Jun 2024 08:32:00 +0200 Subject: [PATCH 04/17] 604 collect stalls metrics (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller --- .../pillarbox/player/PillarboxExoPlayer.kt | 2 + .../pillarbox/player/PillarboxLoadControl.kt | 2 +- .../player/analytics/StallTracker.kt | 130 ++++++++++++++++++ .../analytics/PlaybackSessionManagerTest.kt | 13 +- 4 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 8e5f30fc4..03ad268f2 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -22,6 +22,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager +import ch.srgssr.pillarbox.player.analytics.StallTracker import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter import ch.srgssr.pillarbox.player.asset.timeRange.Credit @@ -147,6 +148,7 @@ class PillarboxExoPlayer internal constructor( if (BuildConfig.DEBUG) { addAnalyticsListener(PillarboxEventLogger()) } + addAnalyticsListener(StallTracker()) } constructor( diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt index 830f90cdc..d74d1c4ee 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxLoadControl.kt @@ -107,7 +107,7 @@ class PillarboxLoadControl( private val DEFAULT_BUFFER_DURATIONS = BufferDurations( bufferForPlayback = 500.milliseconds, bufferForPlaybackAfterRebuffer = 1.seconds, - minBufferDuration = 1.seconds + minBufferDuration = 1.seconds, ) } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt new file mode 100644 index 000000000..e83c0af01 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds + +/** + * Stall tracker + * # Definition of a Stall + * A Stall occurs when the player is buffering during playback without user interaction. + */ +class StallTracker : AnalyticsListener { + private var stallCount = 0 + private var lastStallTime = 0L + private var stallDuration = 0L + private var lastIsPlaying = 0L + private var totalPlaytimeDuration = 0L + + private enum class State { + IDLE, + READY, + STALLED, + SEEKING, + } + + private var state: State = State.IDLE + set(value) { + if (value == field) return + if (value == State.STALLED) { + lastStallTime = System.currentTimeMillis() + stallCount++ + } + if (field == State.STALLED) { + stallDuration += System.currentTimeMillis() - lastStallTime + } + field = value + } + + private fun reset() { + state = State.IDLE + + Log.d(TAG, "Metrics: #Stalls = $stallCount duration = ${stallDuration.milliseconds} totalPlayTime = ${totalPlaytimeDuration.milliseconds}") + stallCount = 0 + lastStallTime = 0L + stallDuration = 0 + totalPlaytimeDuration = 0 + } + + override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { + reset() + } + } + + override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { + reset() + } + + override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { + reset() + } + + override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { + if (isPlaying) { + lastIsPlaying = System.currentTimeMillis() + } else { + totalPlaytimeDuration += System.currentTimeMillis() - lastIsPlaying + } + } + + @Suppress("ComplexCondition") + override fun onPositionDiscontinuity( + eventTime: AnalyticsListener.EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + val isNotStalled = state != State.STALLED + val isSameMediaItem = oldPosition.mediaItemIndex == newPosition.mediaItemIndex + val isSeekDiscontinuity = reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT + + if (isNotStalled && isSameMediaItem && isSeekDiscontinuity) { + state = State.SEEKING + } + } + + override fun onLoadError( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + error: IOException, + wasCanceled: Boolean + ) { + if (state == State.READY || state == State.SEEKING) { + state = State.STALLED + } + } + + override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + state = State.READY + } + + Player.STATE_BUFFERING -> { + if (state == State.READY) { + state = State.STALLED + } + } + + else -> { + reset() + } + } + } + + companion object { + private const val TAG = "Stalls" + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt index 52ab7500e..b7d350e95 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player.analytics import android.content.Context +import android.os.Looper import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -17,6 +18,7 @@ import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.verifyOrder import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -45,6 +47,12 @@ class PlaybackSessionManagerTest { } } + @AfterTest + fun tearDown() { + player.release() + shadowOf(Looper.getMainLooper()).idle() + } + @Test fun `play single media item`() { val mediaItem = MediaItem.fromUri(VOD1) @@ -192,11 +200,6 @@ class PlaybackSessionManagerTest { ) } - @AfterTest - fun tearDown() { - player.release() - } - private companion object { private const val VOD1 = "https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8" private const val VOD2 = "https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8" From e3f057bca2153bcd828714b15bb4c814a4209537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 28 Jun 2024 18:07:11 +0200 Subject: [PATCH 05/17] Enable CI on the `qos` branch (#615) --- .github/workflows/build.yml | 1 + .../analytics/PlaybackSessionManagerTest.kt | 133 ++++++++++-------- 2 files changed, 76 insertions(+), 58 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3141f019..484fbb71f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - qos pull_request: types: [ synchronize, opened, reopened, ready_for_review ] diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt index b7d350e95..63ef8c633 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -13,9 +13,10 @@ 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.test.utils.TestPillarboxRunHelper +import io.mockk.clearAllMocks import io.mockk.confirmVerified import io.mockk.mockk +import io.mockk.verify import io.mockk.verifyOrder import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf @@ -24,12 +25,11 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) class PlaybackSessionManagerTest { private lateinit var clock: FakeClock - private lateinit var player: Player + private lateinit var player: ExoPlayer private lateinit var sessionManagerListener: PlaybackSessionManager.Listener @BeforeTest @@ -49,6 +49,7 @@ class PlaybackSessionManagerTest { @AfterTest fun tearDown() { + clearAllMocks() player.release() shadowOf(Looper.getMainLooper()).idle() } @@ -81,10 +82,7 @@ class PlaybackSessionManagerTest { player.setMediaItem(mediaItem) player.play() - - TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) - - player.removeMediaItem(0) + player.removeMediaItem(player.currentMediaItemIndex) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) @@ -111,58 +109,73 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - val sessions = mutableListOf() + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() - verifyOrder { - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 2 - sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 2 - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 - sessionManagerListener.onSessionFinished(capture(sessions)) // Item 2 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val onSessionCreated = mutableListOf() + val onCurrentSession = mutableListOf() + val onSessionFinished = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(onSessionCreated)) + sessionManagerListener.onCurrentSession(capture(onCurrentSession)) + sessionManagerListener.onSessionFinished(capture(onSessionFinished)) } confirmVerified(sessionManagerListener) - assertEquals(8, sessions.size) - assertEquals(3, sessions.distinctBy { it.sessionId }.size) - assertEquals( - listOf(mediaItems[0], mediaItems[0], mediaItems[1], mediaItems[0], mediaItems[1], mediaItems[2], mediaItems[1], mediaItems[2]), - sessions.map { it.mediaItem }.reversed(), - ) + assertEquals(3, onSessionCreated.size) + assertEquals(3, onCurrentSession.size) + assertEquals(3, onSessionFinished.size) + + assertEquals(3, onSessionCreated.distinctBy { it.sessionId }.size) + assertEquals(3, onCurrentSession.distinctBy { it.sessionId }.size) + assertEquals(3, onSessionFinished.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, onSessionCreated.map { it.mediaItem }) + assertEquals(mediaItems, onCurrentSession.map { it.mediaItem }) + assertEquals(mediaItems, onSessionFinished.map { it.mediaItem }) } @Test fun `play multiple media items, remove upcoming media item`() { val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + val expectedMediaItems = listOf(mediaItems[0], mediaItems[2]) player.setMediaItems(mediaItems) player.play() + player.removeMediaItem(player.currentMediaItemIndex + 1) - TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - player.removeMediaItem(1) + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val sessions = mutableListOf() + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() - verifyOrder { - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 - sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) } confirmVerified(sessionManagerListener) - assertEquals(5, sessions.size) - assertEquals(2, sessions.distinctBy { it.sessionId }.size) - assertEquals( - listOf(mediaItems[0], mediaItems[0], mediaItems[2], mediaItems[0], mediaItems[2]), - sessions.map { it.mediaItem }.reversed(), - ) + assertEquals(2, createdSessions.size) + assertEquals(2, currentSessions.size) + assertEquals(2, finishedSessions.size) + + assertEquals(2, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(2, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(2, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(expectedMediaItems, createdSessions.map { it.mediaItem }) + assertEquals(expectedMediaItems, currentSessions.map { it.mediaItem }) + assertEquals(expectedMediaItems, finishedSessions.map { it.mediaItem }) } @Test @@ -171,33 +184,37 @@ class PlaybackSessionManagerTest { player.setMediaItems(mediaItems) player.play() + player.removeMediaItem(player.currentMediaItemIndex) - TestPillarboxRunHelper.runUntilPosition(player, 5.seconds, clock) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - player.removeMediaItem(0) + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val sessions = mutableListOf() + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() - verifyOrder { - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 1 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 1 - sessionManagerListener.onSessionFinished(capture(sessions)) // Item 1 - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 2 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 2 - sessionManagerListener.onSessionCreated(capture(sessions)) // Item 3 - sessionManagerListener.onSessionFinished(capture(sessions)) // Item 2 - sessionManagerListener.onCurrentSession(capture(sessions)) // Item 3 + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) } confirmVerified(sessionManagerListener) - assertEquals(8, sessions.size) - assertEquals(3, sessions.distinctBy { it.sessionId }.size) - assertEquals( - listOf(mediaItems[0], mediaItems[0], mediaItems[0], mediaItems[1], mediaItems[1], mediaItems[2], mediaItems[1], mediaItems[2]), - sessions.map { it.mediaItem }.reversed(), - ) + assertEquals(3, createdSessions.size) + assertEquals(3, currentSessions.size) + assertEquals(3, finishedSessions.size) + + assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, createdSessions.map { it.mediaItem }) + assertEquals(mediaItems, currentSessions.map { it.mediaItem }) + assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) } private companion object { From 620f5a575dc92d3419de8d5f3a6a6ed5e2345d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 2 Jul 2024 11:20:16 +0200 Subject: [PATCH 06/17] 619 enhance sessionmanager with perioduid (#627) --- .../analytics/PlaybackSessionManager.kt | 106 ++++++++++-------- .../player/qos/QoSSessionAnalyticsListener.kt | 24 ++-- .../analytics/PlaybackSessionManagerTest.kt | 38 +++++++ 3 files changed, 108 insertions(+), 60 deletions(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index bfbdbc628..4901a60f1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -4,9 +4,11 @@ */ package ch.srgssr.pillarbox.player.analytics +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Player.TimelineChangeReason +import androidx.media3.common.Timeline import androidx.media3.common.Timeline.Window import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime @@ -18,7 +20,7 @@ import java.util.UUID /** * Playback session manager - * + * - Session is linked to the period inside the timeline. [Timeline.getUidOfPeriod]. * - Session is created when the player does something with a [MediaItem]. * - Session is current if the media item associated with session is the current [MediaItem]. * - Session is finished when it is no longer the current session or when the session is removed from the player. @@ -57,29 +59,20 @@ class PlaybackSessionManager( /** * Session * + * @property periodUid The periodUid from [Timeline.getUidOfPeriod] for [mediaItem]. * @property mediaItem The [MediaItem] linked to the session. */ - class Session(val mediaItem: MediaItem) { + data class Session( + val periodUid: Any, + val mediaItem: MediaItem, + ) { /** * Unique Session Id */ val sessionId = UUID.randomUUID().toString() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Session - - return sessionId == other.sessionId - } - - override fun hashCode(): Int { - return sessionId.hashCode() - } } - private val sessions = HashMap() + private val sessions = HashMap() private val window = Window() /** @@ -90,7 +83,7 @@ class PlaybackSessionManager( if (field != value) { field?.let { listener.onSessionFinished(it) - sessions.remove(it.sessionId) + sessions.remove(it.periodUid) } field = value field?.let { @@ -100,16 +93,42 @@ class PlaybackSessionManager( } /** - * Get or create a session from a [MediaItem]. + * Get session from [periodUid] * - * @param mediaItem The [MediaItem]. - * @return A [Session] associated with `mediaItem`. + * @param periodUid The period unique id [Timeline.getUidOfPeriod]. + * @return null if session doesn't exist. */ - fun getOrCreateSession(mediaItem: MediaItem): Session { - val session = sessions.values.firstOrNull { it.mediaItem.isTheSame(mediaItem) } + fun getSessionFromPeriodUid(periodUid: Any): Session? { + return sessions[periodUid] + } + + /** + * Get session from event time + * + * @param eventTime The [EventTime]. + * @return null if session doesn't exist. + */ + fun getSessionFromEventTime(eventTime: EventTime): Session? { + val windowIndex = eventTime.windowIndex + eventTime.timeline.getWindow(windowIndex, window) + val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + return sessions[periodUid] + } + + /** + * Get or create a session from a [EventTime]. + * + * @param eventTime The [EventTime]. + * @return A [Session] associated with `eventTime`. + */ + fun getOrCreateSession(eventTime: EventTime): Session { + val windowIndex = eventTime.windowIndex + eventTime.timeline.getWindow(windowIndex, window) + val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + val session = sessions[periodUid] if (session == null) { - val newSession = Session(mediaItem) - sessions[newSession.sessionId] = newSession + val newSession = Session(periodUid, eventTime.getMediaItem()) + sessions[periodUid] = newSession listener.onSessionCreated(newSession) if (currentSession == null) { currentSession = newSession @@ -129,7 +148,7 @@ class PlaybackSessionManager( val newItemIndex = newPosition.mediaItemIndex DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { - val newSession = getOrCreateSession(eventTime.timeline.getWindow(newItemIndex, window).mediaItem) + val newSession = getOrCreateSession(eventTime) currentSession = newSession } } @@ -139,7 +158,11 @@ class PlaybackSessionManager( TAG, "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}" ) - currentSession = mediaItem?.let { getOrCreateSession(it) } + currentSession = if (mediaItem == null) { + null + } else { + getOrCreateSession(eventTime) + } } override fun onTimelineChanged(eventTime: EventTime, @TimelineChangeReason reason: Int) { @@ -148,30 +171,25 @@ class PlaybackSessionManager( finishAllSession() return } + // Finish sessions that are no more in the timeline. val timeline = eventTime.timeline - val listNewItems = ArrayList() - for (i in 0 until timeline.windowCount) { - val mediaItem = timeline.getWindow(i, window).mediaItem - listNewItems.add(mediaItem) - } - val sessions = HashSet(sessions.values) - for (session in sessions) { - val matchingItem = listNewItems.firstOrNull { it.isTheSame(session.mediaItem) } - if (matchingItem == null) { - if (session == currentSession) currentSession = null - else { + val currentSessions = HashSet(sessions.values) + for (session in currentSessions) { + val periodUid = session.periodUid + val periodIndex = timeline.getIndexOfPeriod(periodUid) + if (periodIndex == C.INDEX_UNSET) { + if (session == currentSession) { + currentSession = null + } else { listener.onSessionFinished(session) - this.sessions.remove(session.sessionId) + this.sessions.remove(session.periodUid) } } } } override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { - val mediaItem = eventTime.getMediaItem() - if (mediaItem != MediaItem.EMPTY) { - getOrCreateSession(mediaItem) - } + getOrCreateSession(eventTime) } override fun onPlayerReleased(eventTime: EventTime) { @@ -195,9 +213,5 @@ class PlaybackSessionManager( if (timeline.isEmpty) return MediaItem.EMPTY return timeline.getWindow(windowIndex, Window()).mediaItem } - - private fun MediaItem.isTheSame(mediaItem: MediaItem): Boolean { - return mediaId == mediaItem.mediaId && localConfiguration?.uri == mediaItem.localConfiguration?.uri - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt index e496bd512..9b5d0f2ff 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt @@ -6,7 +6,6 @@ package ch.srgssr.pillarbox.player.qos import android.content.Context import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.Timeline import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.source.LoadEventInfo @@ -20,14 +19,14 @@ internal class QoSSessionAnalyticsListener( private val onQoSSessionReady: (qosSession: QoSSession) -> Unit, ) : AnalyticsListener { private val loadingSessions = mutableSetOf() - private val mediaIdToSessionId = mutableMapOf() + private val periodUidToSessionId = mutableMapOf() private val currentSessionToMediaStart = mutableMapOf() private val qosSessions = mutableMapOf() private val window = Timeline.Window() fun onSessionCreated(session: PlaybackSessionManager.Session) { loadingSessions.add(session.sessionId) - mediaIdToSessionId[session.mediaItem.mediaId] = session.sessionId + periodUidToSessionId[session.periodUid] = session.sessionId qosSessions[session.sessionId] = QoSSession( context = context, mediaId = session.mediaItem.mediaId, @@ -41,7 +40,7 @@ internal class QoSSessionAnalyticsListener( fun onSessionFinished(session: PlaybackSessionManager.Session) { loadingSessions.remove(session.sessionId) - mediaIdToSessionId.remove(session.mediaItem.mediaId) + periodUidToSessionId.remove(session.periodUid) qosSessions.remove(session.sessionId) } @@ -50,8 +49,7 @@ internal class QoSSessionAnalyticsListener( loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData, ) { - val mediaItem = getMediaItem(eventTime) - val sessionId = mediaIdToSessionId[mediaItem?.mediaId] + val sessionId = getSessionId(eventTime) if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessions) { return } @@ -86,8 +84,7 @@ internal class QoSSessionAnalyticsListener( } private fun notifyQoSSessionReady(eventTime: AnalyticsListener.EventTime) { - val mediaItem = getMediaItem(eventTime) - val sessionId = mediaIdToSessionId[mediaItem?.mediaId] ?: return + val sessionId = getSessionId(eventTime) ?: return if (loadingSessions.remove(sessionId)) { qosSessions[sessionId]?.let { @@ -107,11 +104,10 @@ internal class QoSSessionAnalyticsListener( } } - private fun getMediaItem(eventTime: AnalyticsListener.EventTime): MediaItem? { - return if (eventTime.timeline.isEmpty) { - null - } else { - eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem - } + private fun getSessionId(eventTime: AnalyticsListener.EventTime): String? { + val timeline = eventTime.timeline + timeline.getWindow(eventTime.windowIndex, window) + val sessionId = periodUidToSessionId[timeline.getUidOfPeriod(window.firstPeriodIndex)] + return sessionId } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt index 63ef8c633..fd959b649 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -217,6 +217,44 @@ class PlaybackSessionManagerTest { assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) } + @Test + fun `play multiple same media items create multiple sessions`() { + val mediaItems = listOf(VOD1, VOD1, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, createdSessions.size) + assertEquals(3, currentSessions.size) + assertEquals(3, finishedSessions.size) + + assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, createdSessions.map { it.mediaItem }) + assertEquals(mediaItems, currentSessions.map { it.mediaItem }) + assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) + } + private companion object { private const val VOD1 = "https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8" private const val VOD2 = "https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8" From 1cbe9943923878538920a7555496a29a05bf4932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Thu, 11 Jul 2024 14:04:27 +0200 Subject: [PATCH 07/17] 625 consolidate metrics management (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller Co-authored-by: Gaëtan Muller --- .../core/business/DefaultPillarbox.kt | 6 + .../commandersact/CommandersActStreaming.kt | 83 ++--- .../CommandersActTrackerIntegrationTest.kt | 1 + .../ComScoreTrackerIntegrationTest.kt | 1 + .../pillarbox/player/PillarboxExoPlayer.kt | 54 ++- .../player/analytics/MetricsCollector.kt | 135 +++++++ .../analytics/PillarboxAnalyticsCollector.kt | 21 +- .../analytics/PillarboxAnalyticsListener.kt | 16 +- .../analytics/PlaybackSessionManager.kt | 209 +++-------- .../player/analytics/PlaybackStats.kt | 26 ++ .../player/analytics/StallDetector.kt | 135 +++++++ .../player/qos/PillarboxEventsDispatcher.kt | 236 ++++++++++++ .../pillarbox/player/qos/QoSCoordinator.kt | 185 ++++++++++ .../srgssr/pillarbox/player/qos/QoSEvent.kt | 4 +- .../player/qos/QoSEventsDispatcher.kt | 123 +++++++ .../pillarbox/player/qos/QoSMessageHandler.kt | 30 ++ .../srgssr/pillarbox/player/qos/QoSSession.kt | 2 + .../player/qos/QoSSessionAnalyticsListener.kt | 113 ------ .../player/qos/StartupTimesTracker.kt | 84 +++++ .../pillarbox/player/utils/Heartbeat.kt | 71 ++++ .../player/utils/PillarboxEventLogger.kt | 4 + .../player/PillarboxExoPlayerMediaItemTest.kt | 2 + .../player/PlayerCallbackFlowTest.kt | 2 + .../TestPillarboxExoPlayerPlaybackSpeed.kt | 2 + .../QoSEventsDispatcherTest.kt} | 135 ++++--- .../pillarbox/player/qos/QoSSessionTest.kt | 1 + ...enerTest.kt => StartupTimesTrackerTest.kt} | 75 ++-- .../tracker/BlockedTimeRangeTrackerTest.kt | 2 + .../player/tracker/ChapterTrackerTest.kt | 2 + ...urrentMediaItemPillarboxDataTrackerTest.kt | 2 + .../player/tracker/MediaItemTrackerTest.kt | 2 + .../pillarbox/player/utils/HeartbeatTest.kt | 340 ++++++++++++++++++ 32 files changed, 1649 insertions(+), 455 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallDetector.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt rename pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/{analytics/PlaybackSessionManagerTest.kt => qos/QoSEventsDispatcherTest.kt} (55%) rename pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/{QoSSessionAnalyticsListenerTest.kt => StartupTimesTrackerTest.kt} (59%) create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt index 263e4b02c..b91265e84 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt @@ -18,6 +18,8 @@ import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.SeekIncrement import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerProvider +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds /** @@ -50,6 +52,7 @@ object DefaultPillarbox { mediaCompositionService = mediaCompositionService, loadControl = loadControl, clock = Clock.DEFAULT, + coroutineContext = Dispatchers.Default, ) } @@ -62,6 +65,7 @@ object DefaultPillarbox { * @param loadControl The load control, by default [DefaultLoadControl]. * @param mediaCompositionService The [MediaCompositionService] to use, by default [HttpMediaCompositionService]. * @param clock The internal clock used by the player. + * @param coroutineContext The coroutine context to use for this player. * @return [PillarboxExoPlayer] suited for SRG. */ @VisibleForTesting @@ -72,6 +76,7 @@ object DefaultPillarbox { loadControl: LoadControl = DefaultLoadControl(), mediaCompositionService: MediaCompositionService = HttpMediaCompositionService(), clock: Clock, + coroutineContext: CoroutineContext, ): PillarboxExoPlayer { return PillarboxExoPlayer( context = context, @@ -82,6 +87,7 @@ object DefaultPillarbox { mediaItemTrackerProvider = mediaItemTrackerRepository, loadControl = loadControl, clock = clock, + coroutineContext = coroutineContext, ) } } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt index 9df6df7a1..77745dbe6 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt @@ -17,17 +17,7 @@ import ch.srgssr.pillarbox.player.extension.hasAccessibilityRoles import ch.srgssr.pillarbox.player.extension.isForced import ch.srgssr.pillarbox.player.tracks.audioTracks import ch.srgssr.pillarbox.player.utils.DebugLogger -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import ch.srgssr.pillarbox.player.utils.Heartbeat import kotlin.coroutines.CoroutineContext import kotlin.math.abs import kotlin.time.Duration @@ -35,7 +25,6 @@ import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -@Suppress("MagicNumber", "TooManyFunctions") internal class CommandersActStreaming( private val commandersAct: CommandersAct, private val player: ExoPlayer, @@ -47,8 +36,29 @@ internal class CommandersActStreaming( Idle, Playing, Paused, HasSeek } + private val positionHeartbeat = Heartbeat( + startDelay = HEART_BEAT_DELAY, + period = POS_PERIOD, + coroutineContext = coroutineContext, + task = { + if (player.playWhenReady) { + notifyPos(player.currentPosition.milliseconds) + } + }, + ) + + private val uptimeHeartbeat = Heartbeat( + startDelay = HEART_BEAT_DELAY, + period = UPTIME_PERIOD, + coroutineContext = coroutineContext, + task = { + if (player.playWhenReady && player.isCurrentMediaItemLive) { + notifyUptime(player.currentPosition.milliseconds) + } + }, + ) + private var state: State = State.Idle - private var heartBeatJob: Job? = null private val playtimeTracker = TotalPlaytimeCounter() init { @@ -61,50 +71,13 @@ internal class CommandersActStreaming( private fun startHeartBeat() { stopHeartBeat() - heartBeatJob = CoroutineScope(coroutineContext).launch(CoroutineName("pillarbox-heart-beat")) { - val posUpdate = periodicTask( - period = POS_PERIOD, - task = ::notifyPos, - ) - val uptimeUpdate = periodicTask( - period = UPTIME_PERIOD, - continueLooping = { runOnMain(player::isCurrentMediaItemLive) }, - task = ::notifyUptime, - ) - - awaitAll(posUpdate, uptimeUpdate) - } - } - - private fun CoroutineScope.periodicTask( - period: Duration, - continueLooping: () -> Boolean = { true }, - task: (currentPosition: Duration) -> Unit - ): Deferred { - return async { - delay(HEART_BEAT_DELAY) - - while (isActive && continueLooping()) { - runOnMain { - if (player.playWhenReady) { - task(player.currentPosition.milliseconds) - } - } - - delay(period) - } - } - } - - private fun runOnMain(callback: () -> T): T { - return runBlocking(Dispatchers.Main) { - callback() - } + positionHeartbeat.start() + uptimeHeartbeat.start() } private fun stopHeartBeat() { - heartBeatJob?.cancel() - heartBeatJob = null + positionHeartbeat.stop() + uptimeHeartbeat.stop() } override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { @@ -228,7 +201,7 @@ internal class CommandersActStreaming( event.subtitleSelectionLanguage = selectedFormat.language ?: C.LANGUAGE_UNDETERMINED event.isSubtitlesOn = true } - } catch (e: NoSuchElementException) { + } catch (_: NoSuchElementException) { event.isSubtitlesOn = false event.subtitleSelectionLanguage = null } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index 1e033c436..8b33f69e8 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -90,6 +90,7 @@ class CommandersActTrackerIntegrationTest { mediaItemTrackerRepository = mediaItemTrackerRepository, mediaCompositionService = mediaCompositionWithFallbackService, clock = clock, + coroutineContext = testDispatcher, ) } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt index 2386c6612..00a018011 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt @@ -65,6 +65,7 @@ class ComScoreTrackerIntegrationTest { mediaItemTrackerRepository = mediaItemTrackerRepository, mediaCompositionService = mediaCompositionWithFallbackService, clock = clock, + coroutineContext = EmptyCoroutineContext, ) } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 03ad268f2..cbdaf366c 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -5,7 +5,6 @@ package ch.srgssr.pillarbox.player import android.content.Context -import android.util.Log import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -20,8 +19,8 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.StallTracker import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter @@ -29,8 +28,10 @@ import ch.srgssr.pillarbox.player.asset.timeRange.Credit 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.qos.QoSSession -import ch.srgssr.pillarbox.player.qos.QoSSessionAnalyticsListener +import ch.srgssr.pillarbox.player.qos.DummyQoSHandler +import ch.srgssr.pillarbox.player.qos.PillarboxEventsDispatcher +import ch.srgssr.pillarbox.player.qos.QoSCoordinator +import ch.srgssr.pillarbox.player.qos.StartupTimesTracker import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemPillarboxDataTracker @@ -38,11 +39,14 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerProvider import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import ch.srgssr.pillarbox.player.tracker.TimeRangeTracker import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext /** * Pillarbox player * * @param context + * @param coroutineContext * @param exoPlayer * @param mediaItemTrackerProvider * @param analyticsCollector @@ -51,6 +55,7 @@ import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger */ class PillarboxExoPlayer internal constructor( context: Context, + coroutineContext: CoroutineContext, private val exoPlayer: ExoPlayer, mediaItemTrackerProvider: MediaItemTrackerProvider, analyticsCollector: PillarboxAnalyticsCollector, @@ -116,33 +121,18 @@ class PillarboxExoPlayer internal constructor( ) init { - val qoSSessionAnalyticsListener = QoSSessionAnalyticsListener(context, ::handleQoSSession) - val sessionManagerListener = object : PlaybackSessionManager.Listener { - private val TAG = "SessionManager" - private fun PlaybackSessionManager.Session.prettyString(): String { - return "$sessionId / ${mediaItem.mediaMetadata.title}" - } - - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onSessionCreated ${session.prettyString()}") - qoSSessionAnalyticsListener.onSessionCreated(session) - } - - override fun onSessionFinished(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onSessionFinished ${session.prettyString()}") - qoSSessionAnalyticsListener.onSessionFinished(session) - } + QoSCoordinator( + context = context, + player = this, + eventsDispatcher = PillarboxEventsDispatcher(), + startupTimesTracker = StartupTimesTracker(), + metricsCollector = MetricsCollector(this), + messageHandler = DummyQoSHandler, + coroutineContext = coroutineContext, + ) - override fun onCurrentSession(session: PlaybackSessionManager.Session) { - Log.i(TAG, "onCurrentSession ${session.prettyString()}") - qoSSessionAnalyticsListener.onCurrentSession(session) - } - } - - addAnalyticsListener(PlaybackSessionManager(sessionManagerListener)) addListener(analyticsCollector) exoPlayer.addListener(ComponentListener()) - exoPlayer.addAnalyticsListener(qoSSessionAnalyticsListener) itemPillarboxDataTracker.addCallback(timeRangeTracker) itemPillarboxDataTracker.addCallback(analyticsTracker) if (BuildConfig.DEBUG) { @@ -164,6 +154,7 @@ class PillarboxExoPlayer internal constructor( mediaItemTrackerProvider = mediaItemTrackerProvider, seekIncrement = seekIncrement, clock = Clock.DEFAULT, + coroutineContext = Dispatchers.Default, ) @VisibleForTesting @@ -174,9 +165,11 @@ class PillarboxExoPlayer internal constructor( mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), seekIncrement: SeekIncrement = SeekIncrement(), clock: Clock, + coroutineContext: CoroutineContext, analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), ) : this( context, + coroutineContext, ExoPlayer.Builder(context) .setClock(clock) .setUsePlatformDiagnostics(false) @@ -376,11 +369,6 @@ class PillarboxExoPlayer internal constructor( playbackParameters = playbackParameters.withSpeed(speed) } - private fun handleQoSSession(qosSession: QoSSession) { - // TODO Do something with the session - Log.d("PillarboxExoPlayer", "[${qosSession.mediaId}] $qosSession") - } - private fun seekEnd() { isSeeking = false pendingSeek?.let { pendingPosition -> diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt new file mode 100644 index 000000000..0c271984a --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.DecoderCounters +import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Playback stats metrics + * Compute playback stats metrics likes stalls, playtime, bitrate, etc.. + */ +class MetricsCollector(private val player: ExoPlayer) : PillarboxAnalyticsListener { + + private var stallCount = 0 + private var lastStallTime = 0L + private var totalStallDuration = Duration.ZERO + private var lastIsPlayingTime = if (player.isPlaying) System.currentTimeMillis() else 0L + private var totalPlaytimeDuration = Duration.ZERO + + private var bandwidth = 0L + private var bufferDuration = Duration.ZERO + + private var audioFormat: Format? = player.audioFormat + private var videoFormat: Format? = player.videoFormat + + override fun onStallChanged(eventTime: AnalyticsListener.EventTime, isStall: Boolean) { + if (isStall) { + lastStallTime = System.currentTimeMillis() + stallCount++ + } else { + totalStallDuration += computeStallDuration() + lastStallTime = 0 + } + } + + override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { + if (isPlaying) { + lastIsPlayingTime = System.currentTimeMillis() + } else { + totalPlaytimeDuration += computePlaybackDuration() + lastIsPlayingTime = 0 + } + } + + override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { + reset() + } + } + + override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { + bandwidth = bitrateEstimate + } + + override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + videoFormat = format + } + + override fun onVideoDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { + videoFormat = null + } + + override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + audioFormat = format + } + + override fun onAudioDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { + audioFormat = null + } + + override fun onEvents(player: Player, events: AnalyticsListener.Events) { + bufferDuration = player.totalBufferedDuration.milliseconds + } + + private fun computePlaybackDuration(): Duration { + return if (lastIsPlayingTime > 0) (System.currentTimeMillis() - lastIsPlayingTime).milliseconds + else Duration.ZERO + } + + private fun computeStallDuration(): Duration { + return if (lastStallTime > 0) (System.currentTimeMillis() - lastStallTime).milliseconds + else Duration.ZERO + } + + private fun computeBitrate(): Int { + val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE + val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE + var bitrate = 0 + if (videoBitrate > 0) bitrate += videoBitrate + if (audioBitrate > 0) bitrate += audioBitrate + return bitrate + } + + private fun reset() { + stallCount = 0 + totalStallDuration = Duration.ZERO + lastStallTime = 0 + + lastIsPlayingTime = 0 + totalPlaytimeDuration = Duration.ZERO + + bufferDuration = Duration.ZERO + + audioFormat = player.audioFormat + videoFormat = player.videoFormat + if (player.isPlaying) { + lastIsPlayingTime = System.currentTimeMillis() + } + } + + /** + * Get current metrics + * + * @return metrics to the current time + */ + fun getCurrentMetrics(): PlaybackStats { + return PlaybackStats( + bandwidth = bandwidth, + bitrate = computeBitrate(), + bufferDuration = bufferDuration, + playbackDuration = totalPlaytimeDuration + computePlaybackDuration(), + stallCount = stallCount, + stallDuration = totalStallDuration + computeStallDuration() + ) + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsCollector.kt index 317389f66..50efc8833 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsCollector.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsCollector.kt @@ -22,7 +22,26 @@ import ch.srgssr.pillarbox.player.asset.timeRange.Credit */ class PillarboxAnalyticsCollector( clock: Clock = Clock.DEFAULT -) : DefaultAnalyticsCollector(clock), PillarboxPlayer.Listener { +) : DefaultAnalyticsCollector(clock), PillarboxPlayer.Listener, StallDetector.Listener { + + private val stallDetector = StallDetector() + + init { + addListener(stallDetector) + stallDetector.addListener(this) + } + + override fun release() { + removeListener(stallDetector) + stallDetector.removeListener(this) + super.release() + } + + override fun onStallChanged(isStall: Boolean) { + val eventTime = generateCurrentPlayerMediaPeriodEventTime() + + sendEventPillarbox(eventTime, PillarboxAnalyticsListener.EVENT_STALL_CHANGED) { listener -> listener.onStallChanged(eventTime, isStall) } + } override fun onSmoothSeekingEnabledChanged(smoothSeekingEnabled: Boolean) { val eventTime = generateCurrentPlayerMediaPeriodEventTime() diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsListener.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsListener.kt index 3c8e86756..c95b39b17 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsListener.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PillarboxAnalyticsListener.kt @@ -4,6 +4,7 @@ */ package ch.srgssr.pillarbox.player.analytics +import androidx.media3.common.Player import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime import ch.srgssr.pillarbox.player.PillarboxPlayer @@ -58,11 +59,19 @@ interface PillarboxAnalyticsListener : AnalyticsListener { * - The use seeks to a new position. * - The playlist changes. * - * @param eventTime The [EventTime] + * @param eventTime The [EventTime]. * @param credit `null` when the current position is not in a Credit. */ fun onCreditChanged(eventTime: EventTime, credit: Credit?) {} + /** + * A Stall occurs when the player is [Player.STATE_BUFFERING] after being [Player.STATE_READY] during playback without user interactions. + * + * @param eventTime The [EventTime]. + * @param isStall true when the player is stalling. + */ + fun onStallChanged(eventTime: EventTime, isStall: Boolean) {} + companion object { /** * @see [PillarboxPlayer.EVENT_BLOCKED_TIME_RANGE_REACHED] @@ -88,5 +97,10 @@ interface PillarboxAnalyticsListener : AnalyticsListener { * @see [PillarboxPlayer.EVENT_SMOOTH_SEEKING_ENABLED_CHANGED] */ const val EVENT_SMOOTH_SEEKING_ENABLED_CHANGED = PillarboxPlayer.EVENT_SMOOTH_SEEKING_ENABLED_CHANGED + + /** + * Event Stall Changed + */ + const val EVENT_STALL_CHANGED = 200 } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index 4901a60f1..8534240e4 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -4,34 +4,39 @@ */ package ch.srgssr.pillarbox.player.analytics -import androidx.media3.common.C import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Player.TimelineChangeReason -import androidx.media3.common.Timeline -import androidx.media3.common.Timeline.Window -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.utils.DebugLogger -import ch.srgssr.pillarbox.player.utils.StringUtil import java.util.UUID /** * Playback session manager - * - Session is linked to the period inside the timeline. [Timeline.getUidOfPeriod]. - * - Session is created when the player does something with a [MediaItem]. - * - Session is current if the media item associated with session is the current [MediaItem]. - * - Session is finished when it is no longer the current session or when the session is removed from the player. * - * @param listener The listener attached to the session manager. + * @constructor Create empty Playback session manager */ -class PlaybackSessionManager( - private val listener: Listener, -) : AnalyticsListener { +class PlaybackSessionManager : PillarboxAnalyticsListener { + + /** + * - A session is linked to the period inside the timeline, see [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod]. + * - A session is created when the player does something with a [MediaItem]. + * - A session is current if the media item associated with the session is the current [MediaItem]. + * - A session is finished when it is no longer the current session, or when the session is removed from the player. + * + * @property periodUid The period id from [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod] for [mediaItem]. + * @property mediaItem The [MediaItem] linked to the session. + */ + data class Session( + val periodUid: Any, + val mediaItem: MediaItem, + ) { + /** + * Unique session id. + */ + val sessionId = UUID.randomUUID().toString() + } + /** * Listener + * + * @constructor Create empty Listener */ interface Listener { /** @@ -39,179 +44,57 @@ class PlaybackSessionManager( * * @param session */ - fun onSessionCreated(session: Session) + fun onSessionCreated(session: Session) = Unit /** - * On session finished + * On current session * * @param session */ - fun onSessionFinished(session: Session) + fun onCurrentSession(session: Session) = Unit /** - * On current session + * On session finished * * @param session */ - fun onCurrentSession(session: Session) + fun onSessionFinished(session: Session) = Unit } /** - * Session + * Add listener * - * @property periodUid The periodUid from [Timeline.getUidOfPeriod] for [mediaItem]. - * @property mediaItem The [MediaItem] linked to the session. + * @param listener */ - data class Session( - val periodUid: Any, - val mediaItem: MediaItem, - ) { - /** - * Unique Session Id - */ - val sessionId = UUID.randomUUID().toString() + fun addListener(listener: Listener) { + TODO("Implement addListener") } - private val sessions = HashMap() - private val window = Window() - - /** - * Current session - */ - var currentSession: Session? = null - private set(value) { - if (field != value) { - field?.let { - listener.onSessionFinished(it) - sessions.remove(it.periodUid) - } - field = value - field?.let { - listener.onCurrentSession(it) - } - } - } - /** - * Get session from [periodUid] + * Remove listener * - * @param periodUid The period unique id [Timeline.getUidOfPeriod]. - * @return null if session doesn't exist. + * @param listener */ - fun getSessionFromPeriodUid(periodUid: Any): Session? { - return sessions[periodUid] + fun removeListener(listener: Listener) { + TODO("implement removeListener") } /** - * Get session from event time + * Get current session * - * @param eventTime The [EventTime]. - * @return null if session doesn't exist. + * @return */ - fun getSessionFromEventTime(eventTime: EventTime): Session? { - val windowIndex = eventTime.windowIndex - eventTime.timeline.getWindow(windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) - return sessions[periodUid] + fun getCurrentSession(): Session? { + TODO("implement getCurrentSession") } /** - * Get or create a session from a [EventTime]. + * Get session from id * - * @param eventTime The [EventTime]. - * @return A [Session] associated with `eventTime`. + * @param sessionId + * @return */ - fun getOrCreateSession(eventTime: EventTime): Session { - val windowIndex = eventTime.windowIndex - eventTime.timeline.getWindow(windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) - val session = sessions[periodUid] - if (session == null) { - val newSession = Session(periodUid, eventTime.getMediaItem()) - sessions[periodUid] = newSession - listener.onSessionCreated(newSession) - if (currentSession == null) { - currentSession = newSession - } - return newSession - } - return session - } - - override fun onPositionDiscontinuity( - eventTime: EventTime, - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - val oldItemIndex = oldPosition.mediaItemIndex - val newItemIndex = newPosition.mediaItemIndex - DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") - if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { - val newSession = getOrCreateSession(eventTime) - currentSession = newSession - } - } - - override fun onMediaItemTransition(eventTime: EventTime, mediaItem: MediaItem?, reason: Int) { - DebugLogger.debug( - TAG, - "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}" - ) - currentSession = if (mediaItem == null) { - null - } else { - getOrCreateSession(eventTime) - } - } - - override fun onTimelineChanged(eventTime: EventTime, @TimelineChangeReason reason: Int) { - DebugLogger.debug(TAG, "onTimelineChanged ${StringUtil.timelineChangeReasonString(reason)} ${eventTime.getMediaItem().mediaMetadata.title}") - if (eventTime.timeline.isEmpty) { - finishAllSession() - return - } - // Finish sessions that are no more in the timeline. - val timeline = eventTime.timeline - val currentSessions = HashSet(sessions.values) - for (session in currentSessions) { - val periodUid = session.periodUid - val periodIndex = timeline.getIndexOfPeriod(periodUid) - if (periodIndex == C.INDEX_UNSET) { - if (session == currentSession) { - currentSession = null - } else { - listener.onSessionFinished(session) - this.sessions.remove(session.periodUid) - } - } - } - } - - override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { - getOrCreateSession(eventTime) - } - - override fun onPlayerReleased(eventTime: EventTime) { - DebugLogger.debug(TAG, "onPlayerReleased") - finishAllSession() - } - - private fun finishAllSession() { - currentSession = null - for (session in sessions.values) { - listener.onSessionFinished(session) - } - sessions.clear() - } - - companion object { - - private const val TAG = "SessionManager" - - private fun EventTime.getMediaItem(): MediaItem { - if (timeline.isEmpty) return MediaItem.EMPTY - return timeline.getWindow(windowIndex, Window()).mediaItem - } + fun getSessionFromId(sessionId: String): Session? { + TODO("implement getSessionFromId") } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt new file mode 100644 index 000000000..432a0e9bb --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import kotlin.time.Duration + +/** + * Represents a generic event, which contains metrics about the current media stream. + * + * @property bandwidth The device-measured network bandwidth, in bytes per second. + * @property bitrate The bitrate of the current stream, in bytes per second. + * @property bufferDuration The forward duration of the buffer, in milliseconds. + * @property playbackDuration The duration of the playback, in milliseconds. + * @property stallCount The number of stalls that have occurred, not as a result of a seek. + * @property stallDuration The total duration of the stalls, in milliseconds. + */ +data class PlaybackStats( + val bandwidth: Long, + val bitrate: Int, + val bufferDuration: Duration, + val playbackDuration: Duration, + val stallCount: Int, + val stallDuration: Duration, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallDetector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallDetector.kt new file mode 100644 index 000000000..bd263c59a --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallDetector.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import java.io.IOException + +/** + * Stall detector + * + * A Stall occurs when the player is [Player.STATE_BUFFERING] after being [Player.STATE_READY] during playback without user interactions. + */ +internal class StallDetector : AnalyticsListener { + + /** + * Listener + */ + interface Listener { + /** + * Called when the player stall state changed. + * + * @param isStall the stall state. + */ + fun onStallChanged(isStall: Boolean) + } + + private enum class State { + IDLE, + READY, + STALLED, + SEEKING, + } + + private val listeners = mutableSetOf() + + private var state: State = State.IDLE + set(value) { + if (value == field) return + if (field == State.STALLED) { + notifyStall(false) + } + if (value == State.STALLED) { + notifyStall(true) + } + field = value + } + + /** + * Add listener + * + * @param listener The [Listener] + */ + fun addListener(listener: Listener) { + listeners.add(listener) + } + + /** + * Remove listener + * + * @param listener The [Listener] + */ + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + private fun notifyStall(isStall: Boolean) { + HashSet(listeners).forEach { + it.onStallChanged(isStall) + } + } + + private fun reset() { + state = State.IDLE + } + + override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { + reset() + } + } + + override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { + reset() + } + + override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { + reset() + } + + override fun onPositionDiscontinuity( + eventTime: AnalyticsListener.EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + val isNotStalled = state != State.STALLED + val isSameMediaItem = oldPosition.mediaItemIndex == newPosition.mediaItemIndex + val isSeekDiscontinuity = reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT + + if (isNotStalled && isSameMediaItem && isSeekDiscontinuity) { + state = State.SEEKING + } + } + + override fun onLoadError( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + error: IOException, + wasCanceled: Boolean + ) { + if (state != State.IDLE) { + state = State.STALLED + } + } + + override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + state = State.READY + } + + else -> { + reset() + } + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt new file mode 100644 index 000000000..9fad02278 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK +import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT +import androidx.media3.common.Player.DiscontinuityReason +import androidx.media3.common.Player.MediaItemTransitionReason +import androidx.media3.common.Player.TimelineChangeReason +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener +import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Listener +import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Session +import ch.srgssr.pillarbox.player.utils.DebugLogger +import ch.srgssr.pillarbox.player.utils.StringUtil + +/** + * Pillarbox provided implementation of [QoSEventsDispatcher]. + */ +class PillarboxEventsDispatcher : QoSEventsDispatcher { + private val analyticsListener = EventsDispatcherAnalyticsListener() + private val listeners = mutableSetOf() + + override fun registerPlayer(player: ExoPlayer) { + player.addAnalyticsListener(analyticsListener) + } + + override fun unregisterPlayer(player: ExoPlayer) { + player.removeAnalyticsListener(analyticsListener) + } + + override fun addListener(listener: Listener) { + listeners.add(listener) + } + + override fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + private inline fun notifyListeners(event: Listener.() -> Unit) { + listeners.toList() + .forEach { listener -> + listener.event() + } + } + + private inner class EventsDispatcherAnalyticsListener : PillarboxAnalyticsListener { + private val sessions = mutableMapOf() + private val window = Timeline.Window() + + private var currentSession: Session? = null + set(value) { + if (field != value) { + field?.let { session -> + notifyListeners { onSessionFinished(session) } + sessions.remove(session.periodUid) + } + field = value + field?.let { session -> + notifyListeners { onCurrentSession(session) } + } + } + } + + override fun onPositionDiscontinuity( + eventTime: EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + @DiscontinuityReason reason: Int, + ) { + val oldItemIndex = oldPosition.mediaItemIndex + val newItemIndex = newPosition.mediaItemIndex + + DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") + + if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { + currentSession = getOrCreateSession(eventTime) + } + if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK || reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { + currentSession?.let { + notifyListeners { + onSeek(it) + } + } + } + } + + override fun onMediaItemTransition( + eventTime: EventTime, + mediaItem: MediaItem?, + @MediaItemTransitionReason reason: Int, + ) { + DebugLogger.debug( + TAG, + "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}", + ) + + currentSession = mediaItem?.let { getOrCreateSession(eventTime) } + } + + override fun onPlayerError(eventTime: EventTime, error: PlaybackException) { + val session = getOrCreateSession(eventTime) + session?.let { + notifyListeners { + onError(session) + } + } + } + + override fun onTimelineChanged( + eventTime: EventTime, + @TimelineChangeReason reason: Int, + ) { + val mediaItem = if (eventTime.timeline.isEmpty) { + MediaItem.EMPTY + } else { + eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem + } + + DebugLogger.debug(TAG, "onTimelineChanged reason = ${StringUtil.timelineChangeReasonString(reason)} ${mediaItem.mediaMetadata.title}") + + val timeline = eventTime.timeline + if (timeline.isEmpty) { + finishAllSessions() + return + } + + // Finish sessions that are no longer in the timeline + val currentSessions = sessions.values.toSet() + currentSessions.forEach { session -> + val periodUid = session.periodUid + val periodIndex = timeline.getIndexOfPeriod(periodUid) + if (periodIndex == C.INDEX_UNSET) { + if (session == currentSession) { + currentSession = null + } else { + notifyListeners { onSessionFinished(session) } + sessions.remove(session.periodUid) + } + } + } + } + + override fun onLoadStarted( + eventTime: EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + ) { + getOrCreateSession(eventTime) + } + + override fun onAudioPositionAdvancing( + eventTime: EventTime, + playoutStartSystemTimeMs: Long, + ) { + val session = getOrCreateSession(eventTime) ?: return + + notifyListeners { onMediaStart(session) } + } + + override fun onRenderedFirstFrame( + eventTime: EventTime, + output: Any, + renderTimeMs: Long, + ) { + val session = getOrCreateSession(eventTime) ?: return + + notifyListeners { onMediaStart(session) } + } + + override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { + val session = getOrCreateSession(eventTime) ?: return + if (isStall) { + notifyListeners { onStall(session) } + } + } + + override fun onPlayerReleased(eventTime: EventTime) { + DebugLogger.debug(TAG, "onPlayerReleased") + finishAllSessions() + notifyListeners { onPlayerReleased() } + } + + override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { + val session = getOrCreateSession(eventTime) ?: return + notifyListeners { onIsPlaying(session, isPlaying) } + } + + private fun getOrCreateSession(eventTime: EventTime): Session? { + if (eventTime.timeline.isEmpty) { + return null + } + + eventTime.timeline.getWindow(eventTime.windowIndex, window) + + val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + var session = sessions[periodUid] + if (session == null) { + val newSession = Session(periodUid, window.mediaItem) + sessions[periodUid] = newSession + notifyListeners { onSessionCreated(newSession) } + + if (currentSession == null) { + currentSession = newSession + } + + session = newSession + } + + return session + } + + private fun finishAllSessions() { + currentSession = null + + sessions.values.forEach { session -> + notifyListeners { onSessionFinished(session) } + } + sessions.clear() + } + } + + private companion object { + private const val TAG = "PillarboxEventsDispatcher" + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt new file mode 100644 index 000000000..68440bc74 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.content.Context +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener +import ch.srgssr.pillarbox.player.analytics.PlaybackStats +import ch.srgssr.pillarbox.player.utils.DebugLogger +import ch.srgssr.pillarbox.player.utils.Heartbeat +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + +internal class QoSCoordinator( + private val context: Context, + private val player: ExoPlayer, + private val eventsDispatcher: QoSEventsDispatcher, + private val startupTimesTracker: StartupTimesTracker, + private val metricsCollector: MetricsCollector, + private val messageHandler: QoSMessageHandler, + coroutineContext: CoroutineContext, +) : PillarboxAnalyticsListener { + private val heartbeat = Heartbeat( + period = HEARTBEAT_PERIOD, + coroutineContext = coroutineContext, + task = { + val session = currentSession ?: return@Heartbeat + + sendEvent("HEARTBEAT", session) + }, + ) + + private var url: String = "" + private val sessions = mutableMapOf() + private var currentSession: QoSEventsDispatcher.Session? = null + + init { + eventsDispatcher.registerPlayer(player) + eventsDispatcher.addListener(EventsDispatcherListener()) + eventsDispatcher.addListener(startupTimesTracker) + + player.addAnalyticsListener(startupTimesTracker) + player.addAnalyticsListener(metricsCollector) + player.addAnalyticsListener(this) + } + + override fun onEvents(player: Player, events: AnalyticsListener.Events) { + DebugLogger.debug(TAG, "onEvents ${metricsCollector.getCurrentMetrics()}") + } + + override fun onLoadCompleted( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + ) { + // TODO Check if this is linked to the current session before updating the URL + url = loadEventInfo.uri.toString() + } + + private fun sendEvent( + eventName: String, + session: QoSEventsDispatcher.Session, + data: Any? = null, + ) { + val message = QoSMessage( + data = data ?: metricsCollector.getCurrentMetrics().toQoSEvent(), + eventName = eventName, + sessionId = session.sessionId, + ) + + messageHandler.sendEvent(message) + } + + private fun PlaybackStats.toQoSEvent(): QoSEvent { + val bitrateBytes = bitrate / BITS + val bandwidthBytes = bandwidth / BITS + return QoSEvent( + bandwidth = bandwidthBytes, + bitrate = bitrateBytes, + bufferDuration = bufferDuration.inWholeMilliseconds, + playbackDuration = playbackDuration.inWholeMilliseconds, + playerPosition = player.currentPosition, + stallCount = stallCount, + stallDuration = stallDuration.inWholeSeconds, + url = url, + ) + } + + private inner class EventsDispatcherListener : QoSEventsDispatcher.Listener { + + override fun onCurrentSession(session: QoSEventsDispatcher.Session) { + currentSession = session + } + + override fun onSessionFinished(session: QoSEventsDispatcher.Session) { + heartbeat.stop() + sendEvent("END", session) + currentSession = null + } + + override fun onMediaStart(session: QoSEventsDispatcher.Session) { + val startupTimes = startupTimesTracker.consumeStartupTimes(session.sessionId) ?: return + + heartbeat.start(restart = false) + + sendStartEvent(session, startupTimes) + } + + override fun onIsPlaying( + session: QoSEventsDispatcher.Session, + isPlaying: Boolean, + ) { + if (isPlaying) { + heartbeat.start(restart = false) + } else { + heartbeat.stop() + } + } + + override fun onSeek(session: QoSEventsDispatcher.Session) { + sendEvent("SEEK", session) + } + + override fun onStall(session: QoSEventsDispatcher.Session) { + sendEvent("STALL", session) + } + + override fun onError(session: QoSEventsDispatcher.Session) { + if (!sessions.containsKey(session.sessionId)) { + sendStartEvent(session, QoSSessionTimings.Zero) + } + + player.playerError?.let { + sendEvent( + eventName = "ERROR", + session = session, + data = QoSError( + throwable = it, + playerPosition = player.currentPosition, + severity = QoSError.Severity.FATAL, + ), + ) + } + } + + override fun onPlayerReleased() { + eventsDispatcher.unregisterPlayer(player) + eventsDispatcher.removeListener(this) + eventsDispatcher.removeListener(startupTimesTracker) + + player.removeAnalyticsListener(startupTimesTracker) + player.removeAnalyticsListener(metricsCollector) + player.removeAnalyticsListener(this@QoSCoordinator) + } + + private fun sendStartEvent( + session: QoSEventsDispatcher.Session, + timings: QoSSessionTimings, + ) { + sendEvent( + eventName = "START", + session = session, + data = QoSSession( + context = context, + mediaId = session.mediaItem.mediaId, + mediaSource = session.mediaItem.localConfiguration?.uri.toString(), + timings = timings, + ), + ) + } + } + + private companion object { + private const val BITS = 8 + private val HEARTBEAT_PERIOD = 10.seconds + private const val TAG = "QoSCoordinator" + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt index 7583136bf..628bf9d38 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt @@ -18,11 +18,11 @@ package ch.srgssr.pillarbox.player.qos */ data class QoSEvent( val bandwidth: Long, - val bitrate: Long, + val bitrate: Int, val bufferDuration: Long, val playbackDuration: Long, val playerPosition: Long, - val stallCount: Long, + val stallCount: Int, val stallDuration: Long, val url: String, ) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt new file mode 100644 index 000000000..e5d3ff76a --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import java.util.UUID + +/** + * Events dispatcher that notifies when specific events happen (related to a session, media playback, ...). + */ +interface QoSEventsDispatcher { + /** + * - A session is linked to the period inside the timeline, see [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod]. + * - A session is created when the player does something with a [MediaItem]. + * - A session is current if the media item associated with the session is the current [MediaItem]. + * - A session is finished when it is no longer the current session, or when the session is removed from the player. + * + * @property periodUid The period id from [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod] for [mediaItem]. + * @property mediaItem The [MediaItem] linked to the session. + */ + data class Session( + val periodUid: Any, + val mediaItem: MediaItem, + ) { + /** + * Unique session id. + */ + val sessionId = UUID.randomUUID().toString() + } + + /** + * Listener to be notified for every event dispatched by [QoSEventsDispatcher]. + */ + interface Listener { + /** + * On session created + * + * @param session + */ + fun onSessionCreated(session: Session) = Unit + + /** + * On current session + * + * @param session + */ + fun onCurrentSession(session: Session) = Unit + + /** + * On media start + * + * @param session + */ + fun onMediaStart(session: Session) = Unit + + /** + * On is playing + * + * @param session + * @param isPlaying + */ + fun onIsPlaying( + session: Session, + isPlaying: Boolean, + ) = Unit + + /** + * On seek + * + * @param session + */ + fun onSeek(session: Session) = Unit + + /** + * On stall + * + * @param session + */ + fun onStall(session: Session) = Unit + + /** + * On error + * + * @param session + */ + fun onError(session: Session) = Unit + + /** + * On session finished + * + * @param session + */ + fun onSessionFinished(session: Session) = Unit + + /** + * On player released + */ + fun onPlayerReleased() = Unit + } + + /** + * Register an [ExoPlayer] to this [QoSEventsDispatcher]. + */ + fun registerPlayer(player: ExoPlayer) + + /** + * Unregister an [ExoPlayer] from this [QoSEventsDispatcher]. + */ + fun unregisterPlayer(player: ExoPlayer) + + /** + * Add a [Listener] to this [QoSEventsDispatcher]. + */ + fun addListener(listener: Listener) + + /** + * Remove a [Listener] from this [QoSEventsDispatcher]. + */ + fun removeListener(listener: Listener) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt new file mode 100644 index 000000000..5e4860225 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import android.util.Log + +/** + * QoS message handler + */ +interface QoSMessageHandler { + /** + * Send event + * + * @param event + */ + fun sendEvent(event: QoSMessage) +} + +/** + * Dummy QoS handler + */ +object DummyQoSHandler : QoSMessageHandler { + private const val TAG = "DummyQoSHandler" + + override fun sendEvent(event: QoSMessage) { + Log.d(TAG, "sendEvent($event)") + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt index 856d2d22b..c63c7d1ea 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt @@ -59,6 +59,7 @@ data class QoSSession( context: Context, mediaId: String, mediaSource: String, + timings: QoSSessionTimings ) : this( deviceId = getDeviceId(), deviceType = context.getDeviceType(), @@ -67,6 +68,7 @@ data class QoSSession( origin = context.packageName, screenHeight = context.getWindowBounds().height(), screenWidth = context.getWindowBounds().width(), + timings = timings ) private companion object { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt deleted file mode 100644 index 9b5d0f2ff..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import android.content.Context -import androidx.media3.common.C -import androidx.media3.common.Timeline -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource -import kotlin.time.Duration.Companion.milliseconds - -internal class QoSSessionAnalyticsListener( - private val context: Context, - private val onQoSSessionReady: (qosSession: QoSSession) -> Unit, -) : AnalyticsListener { - private val loadingSessions = mutableSetOf() - private val periodUidToSessionId = mutableMapOf() - private val currentSessionToMediaStart = mutableMapOf() - private val qosSessions = mutableMapOf() - private val window = Timeline.Window() - - fun onSessionCreated(session: PlaybackSessionManager.Session) { - loadingSessions.add(session.sessionId) - periodUidToSessionId[session.periodUid] = session.sessionId - qosSessions[session.sessionId] = QoSSession( - context = context, - mediaId = session.mediaItem.mediaId, - mediaSource = session.mediaItem.localConfiguration?.uri?.toString().orEmpty(), - ) - } - - fun onCurrentSession(session: PlaybackSessionManager.Session) { - currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() - } - - fun onSessionFinished(session: PlaybackSessionManager.Session) { - loadingSessions.remove(session.sessionId) - periodUidToSessionId.remove(session.periodUid) - qosSessions.remove(session.sessionId) - } - - override fun onLoadCompleted( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - ) { - val sessionId = getSessionId(eventTime) - if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessions) { - return - } - - val qosSession = qosSessions.getValue(sessionId) - val initialTimings = qosSession.timings - val loadDuration = loadEventInfo.loadDurationMs.milliseconds - - val timings = when (mediaLoadData.dataType) { - C.DATA_TYPE_DRM -> initialTimings.copy(drm = initialTimings.drm + loadDuration) - C.DATA_TYPE_MANIFEST, C.DATA_TYPE_MEDIA -> initialTimings.copy(mediaSource = initialTimings.mediaSource + loadDuration) - PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> initialTimings.copy(asset = initialTimings.asset + loadDuration) - else -> initialTimings - } - - qosSessions[sessionId] = qosSession.copy(timings = timings) - } - - override fun onAudioPositionAdvancing( - eventTime: AnalyticsListener.EventTime, - playoutStartSystemTimeMs: Long, - ) { - notifyQoSSessionReady(eventTime) - } - - override fun onRenderedFirstFrame( - eventTime: AnalyticsListener.EventTime, - output: Any, - renderTimeMs: Long, - ) { - notifyQoSSessionReady(eventTime) - } - - private fun notifyQoSSessionReady(eventTime: AnalyticsListener.EventTime) { - val sessionId = getSessionId(eventTime) ?: return - - if (loadingSessions.remove(sessionId)) { - qosSessions[sessionId]?.let { - val qosSession = if (sessionId in currentSessionToMediaStart) { - it.copy( - timings = it.timings.copy( - currentToStart = (System.currentTimeMillis() - currentSessionToMediaStart.getValue(sessionId)).milliseconds, - ), - ) - } else { - it - } - - currentSessionToMediaStart.remove(sessionId) - onQoSSessionReady(qosSession) - } - } - } - - private fun getSessionId(eventTime: AnalyticsListener.EventTime): String? { - val timeline = eventTime.timeline - timeline.getWindow(eventTime.windowIndex, window) - val sessionId = periodUidToSessionId[timeline.getUidOfPeriod(window.firstPeriodIndex)] - return sessionId - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt new file mode 100644 index 000000000..cbf55a4c7 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos + +import androidx.media3.common.C +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.source.PillarboxMediaSource +import kotlin.time.Duration.Companion.milliseconds + +internal class StartupTimesTracker : AnalyticsListener, QoSEventsDispatcher.Listener { + private val loadingSessions = mutableSetOf() + private val periodUidToSessionId = mutableMapOf() + private val currentSessionToMediaStart = mutableMapOf() + private val qosSessionsTimings = mutableMapOf() + private val window = Timeline.Window() + + fun consumeStartupTimes(sessionId: String): QoSSessionTimings? { + if (loadingSessions.remove(sessionId)) { + val sessionTimings = checkNotNull(qosSessionsTimings.remove(sessionId)) + val currentSessionToMediaStart = currentSessionToMediaStart.remove(sessionId) + + return if (currentSessionToMediaStart != null) { + sessionTimings.copy(currentToStart = (System.currentTimeMillis() - currentSessionToMediaStart).milliseconds) + } else { + sessionTimings + } + } + + return null + } + + override fun onSessionCreated(session: QoSEventsDispatcher.Session) { + loadingSessions.add(session.sessionId) + periodUidToSessionId[session.periodUid] = session.sessionId + qosSessionsTimings[session.sessionId] = QoSSessionTimings.Zero + } + + override fun onCurrentSession(session: QoSEventsDispatcher.Session) { + currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() + } + + override fun onSessionFinished(session: QoSEventsDispatcher.Session) { + loadingSessions.remove(session.sessionId) + periodUidToSessionId.remove(session.periodUid) + qosSessionsTimings.remove(session.sessionId) + } + + override fun onLoadCompleted( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + ) { + val sessionId = getSessionId(eventTime) + if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessionsTimings) { + return + } + + val qosSessionTimings = qosSessionsTimings.getValue(sessionId) + val loadDuration = loadEventInfo.loadDurationMs.milliseconds + + qosSessionsTimings[sessionId] = when (mediaLoadData.dataType) { + C.DATA_TYPE_DRM -> qosSessionTimings.copy(drm = qosSessionTimings.drm + loadDuration) + C.DATA_TYPE_MANIFEST, C.DATA_TYPE_MEDIA -> qosSessionTimings.copy(mediaSource = qosSessionTimings.mediaSource + loadDuration) + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> qosSessionTimings.copy(asset = qosSessionTimings.asset + loadDuration) + else -> qosSessionTimings + } + } + + private fun getSessionId(eventTime: AnalyticsListener.EventTime): String? { + val timeline = eventTime.timeline + if (timeline.isEmpty) { + return null + } + + val firstPeriodIndex = timeline.getWindow(eventTime.windowIndex, window).firstPeriodIndex + val periodUid = timeline.getUidOfPeriod(firstPeriodIndex) + return periodUidToSessionId[periodUid] + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt new file mode 100644 index 000000000..771736c00 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.utils + +import androidx.annotation.MainThread +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration + +/** + * Utility class to trigger a [task] at a regular [intervals][period]. + * + * @param startDelay The initial delay before the first execution of [task]. + * @param period The period between two executions of [task]. + * @param coroutineContext The coroutine context in which [Heartbeat] is run. + * @param task The task to execute, on the main [Thread] at regular [intervals][period]. + */ +class Heartbeat( + private val startDelay: Duration = Duration.ZERO, + private val period: Duration, + private val coroutineContext: CoroutineContext, + @MainThread private val task: () -> Unit, +) { + private val coroutineScope = CoroutineScope(coroutineContext + CoroutineName("pillarbox-heart-beat")) + + private var job: Job? = null + + /** + * Start the execution of this heartbeat. Does nothing if it is already running and [restart] is `false`. + * + * @param restart `true` to restart the heartbeat if it is already running, `false` otherwise. + * + * @see stop + */ + fun start(restart: Boolean = true) { + if (job?.isActive == true && !restart) { + return + } + + stop() + + job = coroutineScope.launch { + delay(startDelay) + + while (isActive) { + runBlocking(Dispatchers.Main) { + task() + } + + delay(period) + } + } + } + + /** + * Stop the execution of this heartbeat. + */ + fun stop() { + job?.cancel() + job = null + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/PillarboxEventLogger.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/PillarboxEventLogger.kt index 34a8c4f4a..040a5f561 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/PillarboxEventLogger.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/PillarboxEventLogger.kt @@ -23,6 +23,10 @@ import kotlin.time.Duration.Companion.milliseconds class PillarboxEventLogger(private val tag: String = "EventLogger") : EventLogger(tag), PillarboxAnalyticsListener { private val startTimeMs: Long = SystemClock.elapsedRealtime() + override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { + Log.d(tag, getEventString(eventTime, "Stall changed ", isStall.toString())) + } + override fun onTrackingEnabledChanged(eventTime: EventTime, trackingEnabled: Boolean) { Log.d(tag, getEventString(eventTime, "TrackingEnabledChanged", trackingEnabled.toString())) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxExoPlayerMediaItemTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxExoPlayerMediaItemTest.kt index 14e2d7015..9ddfc6aa8 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxExoPlayerMediaItemTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxExoPlayerMediaItemTest.kt @@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import ch.srgssr.pillarbox.player.extension.getCurrentMediaItems import org.junit.Before import org.junit.runner.RunWith +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals @@ -29,6 +30,7 @@ class PillarboxExoPlayerMediaItemTest { seekIncrement = SeekIncrement(), loadControl = DefaultLoadControl(), clock = FakeClock(true), + coroutineContext = EmptyCoroutineContext, ) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerCallbackFlowTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerCallbackFlowTest.kt index 1ec061eab..7c26ef8b7 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerCallbackFlowTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PlayerCallbackFlowTest.kt @@ -19,6 +19,7 @@ import app.cash.turbine.test import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -38,6 +39,7 @@ class PlayerCallbackFlowTest { context = context, loadControl = DefaultLoadControl(), clock = FakeClock(true), + coroutineContext = EmptyCoroutineContext, ).apply { prepare() play() diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxExoPlayerPlaybackSpeed.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxExoPlayerPlaybackSpeed.kt index 21993fca6..fef440ced 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxExoPlayerPlaybackSpeed.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxExoPlayerPlaybackSpeed.kt @@ -22,6 +22,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext @RunWith(AndroidJUnit4::class) class TestPillarboxExoPlayerPlaybackSpeed { @@ -33,6 +34,7 @@ class TestPillarboxExoPlayerPlaybackSpeed { player = PillarboxExoPlayer( context = context, clock = FakeClock(true), + coroutineContext = EmptyCoroutineContext, ) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt similarity index 55% rename from pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt index fd959b649..cf02f8848 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.analytics +package ch.srgssr.pillarbox.player.qos import android.content.Context import android.os.Looper @@ -14,6 +14,7 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.clearAllMocks +import io.mockk.clearMocks import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.verify @@ -27,24 +28,30 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -class PlaybackSessionManagerTest { +class QoSEventsDispatcherTest { private lateinit var clock: FakeClock private lateinit var player: ExoPlayer - private lateinit var sessionManagerListener: PlaybackSessionManager.Listener + private lateinit var eventsDispatcherListener: QoSEventsDispatcher.Listener @BeforeTest fun setUp() { val context = ApplicationProvider.getApplicationContext() clock = FakeClock(true) - sessionManagerListener = mockk(relaxed = true) + eventsDispatcherListener = mockk(relaxed = true) player = ExoPlayer.Builder(context) .setClock(clock) .build() .apply { - addAnalyticsListener(PlaybackSessionManager(sessionManagerListener)) prepare() } + + PillarboxEventsDispatcher().apply { + registerPlayer(player) + addListener(eventsDispatcherListener) + } + + clearMocks(eventsDispatcherListener) } @AfterTest @@ -63,17 +70,29 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - val sessions = mutableListOf() + val onSessionCreated = mutableListOf() + val onCurrentSession = mutableListOf() + val onIsPlayingSessions = mutableListOf() + val onIsPlayingValue = mutableListOf() - verifyOrder { - sessionManagerListener.onSessionCreated(capture(sessions)) - sessionManagerListener.onCurrentSession(capture(sessions)) + verify { + eventsDispatcherListener.onSessionCreated(capture(onSessionCreated)) + eventsDispatcherListener.onCurrentSession(capture(onCurrentSession)) + eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) - assertEquals(2, sessions.size) - assertEquals(1, sessions.distinctBy { it.sessionId }.size) - assertTrue(sessions.all { it.mediaItem == mediaItem }) + assertEquals(1, onSessionCreated.size) + assertEquals(1, onCurrentSession.size) + assertEquals(2, onIsPlayingValue.size) + + assertEquals(1, onSessionCreated.distinctBy { it.sessionId }.size) + assertEquals(1, onCurrentSession.distinctBy { it.sessionId }.size) + assertEquals(1, onIsPlayingSessions.distinctBy { it.sessionId }.size) + + assertEquals(listOf(mediaItem), onSessionCreated.map { it.mediaItem }) + assertEquals(listOf(mediaItem), onCurrentSession.map { it.mediaItem }) + assertEquals(listOf(true, false), onIsPlayingValue) } @Test @@ -86,14 +105,14 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - val sessions = mutableListOf() + val sessions = mutableListOf() verifyOrder { - sessionManagerListener.onSessionCreated(capture(sessions)) - sessionManagerListener.onCurrentSession(capture(sessions)) - sessionManagerListener.onSessionFinished(capture(sessions)) + eventsDispatcherListener.onSessionCreated(capture(sessions)) + eventsDispatcherListener.onCurrentSession(capture(sessions)) + eventsDispatcherListener.onSessionFinished(capture(sessions)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) assertEquals(3, sessions.size) assertEquals(1, sessions.distinctBy { it.sessionId }.size) @@ -114,28 +133,34 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val onSessionCreated = mutableListOf() - val onCurrentSession = mutableListOf() - val onSessionFinished = mutableListOf() + val onSessionCreated = mutableListOf() + val onCurrentSession = mutableListOf() + val onSessionFinished = mutableListOf() + val onIsPlayingSessions = mutableListOf() + val onIsPlayingValue = mutableListOf() verify { - sessionManagerListener.onSessionCreated(capture(onSessionCreated)) - sessionManagerListener.onCurrentSession(capture(onCurrentSession)) - sessionManagerListener.onSessionFinished(capture(onSessionFinished)) + eventsDispatcherListener.onSessionCreated(capture(onSessionCreated)) + eventsDispatcherListener.onCurrentSession(capture(onCurrentSession)) + eventsDispatcherListener.onSessionFinished(capture(onSessionFinished)) + eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) assertEquals(3, onSessionCreated.size) assertEquals(3, onCurrentSession.size) assertEquals(3, onSessionFinished.size) + assertEquals(6, onIsPlayingValue.size) assertEquals(3, onSessionCreated.distinctBy { it.sessionId }.size) assertEquals(3, onCurrentSession.distinctBy { it.sessionId }.size) assertEquals(3, onSessionFinished.distinctBy { it.sessionId }.size) + assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) assertEquals(mediaItems, onSessionCreated.map { it.mediaItem }) assertEquals(mediaItems, onCurrentSession.map { it.mediaItem }) assertEquals(mediaItems, onSessionFinished.map { it.mediaItem }) + assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) } @Test @@ -154,28 +179,34 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() + val onIsPlayingValue = mutableListOf() verify { - sessionManagerListener.onSessionCreated(capture(createdSessions)) - sessionManagerListener.onCurrentSession(capture(currentSessions)) - sessionManagerListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onSessionCreated(capture(createdSessions)) + eventsDispatcherListener.onCurrentSession(capture(currentSessions)) + eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) assertEquals(2, createdSessions.size) assertEquals(2, currentSessions.size) assertEquals(2, finishedSessions.size) + assertEquals(4, onIsPlayingValue.size) assertEquals(2, createdSessions.distinctBy { it.sessionId }.size) assertEquals(2, currentSessions.distinctBy { it.sessionId }.size) assertEquals(2, finishedSessions.distinctBy { it.sessionId }.size) + assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) assertEquals(expectedMediaItems, createdSessions.map { it.mediaItem }) assertEquals(expectedMediaItems, currentSessions.map { it.mediaItem }) assertEquals(expectedMediaItems, finishedSessions.map { it.mediaItem }) + assertEquals(listOf(true, false, true, false), onIsPlayingValue) } @Test @@ -193,28 +224,34 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() + val onIsPlayingValue = mutableListOf() verify { - sessionManagerListener.onSessionCreated(capture(createdSessions)) - sessionManagerListener.onCurrentSession(capture(currentSessions)) - sessionManagerListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onSessionCreated(capture(createdSessions)) + eventsDispatcherListener.onCurrentSession(capture(currentSessions)) + eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) assertEquals(3, createdSessions.size) assertEquals(3, currentSessions.size) assertEquals(3, finishedSessions.size) + assertEquals(4, onIsPlayingValue.size) assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) assertEquals(mediaItems, createdSessions.map { it.mediaItem }) assertEquals(mediaItems, currentSessions.map { it.mediaItem }) assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) + assertEquals(listOf(true, false, true, false), onIsPlayingValue) } @Test @@ -231,28 +268,34 @@ class PlaybackSessionManagerTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() + val onIsPlayingValue = mutableListOf() verify { - sessionManagerListener.onSessionCreated(capture(createdSessions)) - sessionManagerListener.onCurrentSession(capture(currentSessions)) - sessionManagerListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onSessionCreated(capture(createdSessions)) + eventsDispatcherListener.onCurrentSession(capture(currentSessions)) + eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) + eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } - confirmVerified(sessionManagerListener) + confirmVerified(eventsDispatcherListener) assertEquals(3, createdSessions.size) assertEquals(3, currentSessions.size) assertEquals(3, finishedSessions.size) + assertEquals(6, onIsPlayingValue.size) assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) assertEquals(mediaItems, createdSessions.map { it.mediaItem }) assertEquals(mediaItems, currentSessions.map { it.mediaItem }) assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) + assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) } private companion object { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt index 25eb40d5b..28fe635e6 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt @@ -172,6 +172,7 @@ class QoSSessionTest { context = context, mediaId = "urn:rts:video:12345", mediaSource = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY", + timings = QoSSessionTimings.Zero, ) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt similarity index 59% rename from pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt index d60d59407..a2da6937c 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListenerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt @@ -6,44 +6,37 @@ package ch.srgssr.pillarbox.player.qos import android.content.Context import android.os.Looper -import android.view.SurfaceView -import android.view.ViewGroup import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.test.utils.FakeClock import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager +import ch.srgssr.pillarbox.player.analytics.MetricsCollector import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner.Parameters import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest -import kotlin.test.Ignore import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.time.Duration.Companion.seconds @RunWith(ParameterizedRobolectricTestRunner::class) -class QoSSessionAnalyticsListenerTest( +class StartupTimesTrackerTest( private val mediaUrls: List, ) { private lateinit var player: Player - private val qosSessions = mutableListOf() + private lateinit var startupTimesTracker: StartupTimesTracker + private lateinit var sessionId: String @BeforeTest fun setUp() { - player = createPlayer(mediaUrls) { - qosSessions.add(it) - } - - // Attach the Player to a surface - val surfaceView = SurfaceView(ApplicationProvider.getApplicationContext()) - surfaceView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - player.setVideoSurfaceView(surfaceView) + startupTimesTracker = StartupTimesTracker() + player = createPlayer(mediaUrls) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) @@ -54,49 +47,49 @@ class QoSSessionAnalyticsListenerTest( } @Test - @Ignore("SurfaceView/SurfaceHolder not implemented in Robolectric") - fun `qos session analytics listener`() { - assertEquals(mediaUrls, qosSessions.map { it.mediaSource }) + fun `consume startup times`() { + val startupTimes = startupTimesTracker.consumeStartupTimes(sessionId) + + assertNotNull(startupTimes) + assertNull(startupTimesTracker.consumeStartupTimes(sessionId)) } @AfterTest fun tearDown() { player.release() - qosSessions.clear() shadowOf(Looper.getMainLooper()).idle() } - private fun createPlayer( - mediaUrls: List, - callback: (qosSession: QoSSession) -> Unit, - ): Player { + private fun createPlayer(mediaUrls: List): Player { val context = ApplicationProvider.getApplicationContext() - val qosSessionAnalyticsListener = QoSSessionAnalyticsListener(context, callback) - val playbackSessionManagerListener = object : PlaybackSessionManager.Listener { - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - qosSessionAnalyticsListener.onSessionCreated(session) - } - - override fun onCurrentSession(session: PlaybackSessionManager.Session) { - qosSessionAnalyticsListener.onCurrentSession(session) - } - - override fun onSessionFinished(session: PlaybackSessionManager.Session) { - qosSessionAnalyticsListener.onSessionFinished(session) - } - } - val playbackSessionManager = PlaybackSessionManager(playbackSessionManagerListener) + val coroutineContext = EmptyCoroutineContext return PillarboxExoPlayer( context = context, clock = FakeClock(true), + coroutineContext = coroutineContext, ).apply { val mediaItems = mediaUrls.map(MediaItem::fromUri) + val eventsDispatcher = PillarboxEventsDispatcher() + eventsDispatcher.addListener(object : QoSEventsDispatcher.Listener { + override fun onSessionCreated(session: QoSEventsDispatcher.Session) { + sessionId = session.sessionId + } + }) + + QoSCoordinator( + context = context, + player = this, + eventsDispatcher = eventsDispatcher, + startupTimesTracker = startupTimesTracker, + metricsCollector = MetricsCollector(this), + messageHandler = DummyQoSHandler, + coroutineContext = coroutineContext, + ) addMediaItems(mediaItems) - addAnalyticsListener(qosSessionAnalyticsListener) - addAnalyticsListener(playbackSessionManager) + addAnalyticsListener(startupTimesTracker) prepare() play() } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/BlockedTimeRangeTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/BlockedTimeRangeTrackerTest.kt index ea51b5b95..87b25abf2 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/BlockedTimeRangeTrackerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/BlockedTimeRangeTrackerTest.kt @@ -27,6 +27,7 @@ import io.mockk.verify import io.mockk.verifyOrder import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -49,6 +50,7 @@ class BlockedTimeRangeTrackerTest { seekIncrement = SeekIncrement(), loadControl = DefaultLoadControl(), clock = fakeClock, + coroutineContext = EmptyCoroutineContext, mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { addAssetLoader(BlockedAssetLoader(context)) }, diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/ChapterTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/ChapterTrackerTest.kt index f1f9c440c..c28cde57a 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/ChapterTrackerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/ChapterTrackerTest.kt @@ -28,6 +28,7 @@ import io.mockk.verify import io.mockk.verifyOrder import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -49,6 +50,7 @@ class ChapterTrackerTest { seekIncrement = SeekIncrement(), loadControl = DefaultLoadControl(), clock = fakeClock, + coroutineContext = EmptyCoroutineContext, mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { addAssetLoader(ChapterAssetLoader(context)) }, diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemPillarboxDataTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemPillarboxDataTrackerTest.kt index fcf803a6b..f97583e87 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemPillarboxDataTrackerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemPillarboxDataTrackerTest.kt @@ -20,6 +20,7 @@ import io.mockk.mockk import io.mockk.verifyOrder import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -43,6 +44,7 @@ class CurrentMediaItemPillarboxDataTrackerTest { }, mediaItemTrackerProvider = FakeTrackerProvider(FakeMediaItemTracker()), clock = clock, + coroutineContext = EmptyCoroutineContext, ) dataTracker = CurrentMediaItemPillarboxDataTracker(player) 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 index db18cd74c..3ed91f2ce 100644 --- 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 @@ -28,6 +28,7 @@ import org.junit.After import org.junit.Before import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertNotNull @@ -48,6 +49,7 @@ class MediaItemTrackerTest { seekIncrement = SeekIncrement(), loadControl = DefaultLoadControl(), clock = fakeClock, + coroutineContext = EmptyCoroutineContext, mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { addAssetLoader(FakeAssetLoader(context)) }, diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt new file mode 100644 index 000000000..7ed3d49e7 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt @@ -0,0 +1,340 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.utils + +import android.content.Context +import android.os.Looper +import androidx.media3.exoplayer.ExoPlayer +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class HeartbeatTest { + private var taskRunsCount = 0 + + private val task: () -> Unit = { + assertTrue(Looper.getMainLooper().isCurrentThread) + + taskRunsCount++ + } + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + taskRunsCount = 0 + } + + @AfterTest + fun tearDown() { + shadowOf(Looper.getMainLooper()).idle() + + Dispatchers.resetMain() + } + + @Test + fun `verify task execution`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(3, taskRunsCount) + } + + @Test + fun `verify task execution with start delay`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(2, taskRunsCount) + } + + @Test + fun `verify task doesn't execute if not started`() = runTest(testDispatcher) { + Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify task with start delay doesn't execute if not started`() = runTest(testDispatcher) { + Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify task execution with immediate stop`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(1, taskRunsCount) + } + + @Test + fun `verify task execution with start delay and immediate stop`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify task execution with start delay and stop during start delay`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + advanceTimeBy(2.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify task can be restarted`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(6, taskRunsCount) + } + + @Test + fun `verify task with start delay can be restarted`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + heartbeat.start() + advanceTimeBy(25.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(4, taskRunsCount) + } + + @Test + fun `verify not started task can be stopped`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify not started task can be stopped with start delay`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 10.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertEquals(0, taskRunsCount) + } + + @Test + fun `verify multiple start() restart the heartbeat`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 30.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start(restart = true) + advanceTimeBy(10.seconds) + heartbeat.start(restart = true) + advanceTimeBy(10.seconds) + heartbeat.start(restart = true) + advanceTimeBy(2.minutes) + heartbeat.stop() + advanceTimeBy(25.seconds) + + assertEquals(6, taskRunsCount) + } + + @Test + fun `verify multiple start() restart the heartbeat with start delay`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 30.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start(restart = true) + advanceTimeBy(10.seconds) + heartbeat.start(restart = true) + advanceTimeBy(10.seconds) + heartbeat.start(restart = true) + advanceTimeBy(2.minutes) + heartbeat.stop() + advanceTimeBy(25.seconds) + + assertEquals(6, taskRunsCount) + } + + @Test + fun `verify multiple start() don't restart the heartbeat`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + period = 30.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start(restart = false) + advanceTimeBy(10.seconds) + heartbeat.start(restart = false) + advanceTimeBy(10.seconds) + heartbeat.start(restart = false) + advanceTimeBy(2.minutes) + heartbeat.stop() + advanceTimeBy(25.seconds) + + assertEquals(5, taskRunsCount) + } + + @Test + fun `verify multiple start() don't restart the heartbeat with start delay`() = runTest(testDispatcher) { + val heartbeat = Heartbeat( + startDelay = 5.seconds, + period = 30.seconds, + coroutineContext = coroutineContext, + task = task, + ) + + heartbeat.start(restart = false) + advanceTimeBy(10.seconds) + heartbeat.start(restart = false) + advanceTimeBy(10.seconds) + heartbeat.start(restart = false) + advanceTimeBy(2.minutes) + heartbeat.stop() + advanceTimeBy(25.seconds) + + assertEquals(5, taskRunsCount) + } + + @Test + fun `verify player is accessible from the task`() = runTest(testDispatcher) { + val context = ApplicationProvider.getApplicationContext() + val player = ExoPlayer.Builder(context).build() + var taskCalled = false + + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = { + player.currentPosition + taskCalled = true + }, + ) + + heartbeat.start() + advanceTimeBy(15.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertTrue(taskCalled) + + player.release() + } +} From b0bc5b2279dc42fb271e829bb18bedf181db1ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Fri, 12 Jul 2024 10:17:38 +0200 Subject: [PATCH 08/17] Extract session management out of `QoSEventsDispatcher` (#635) --- .../pillarbox/player/PillarboxExoPlayer.kt | 5 +- .../analytics/PlaybackSessionManager.kt | 241 ++++++++++++- .../player/qos/PillarboxEventsDispatcher.kt | 147 +------- .../pillarbox/player/qos/QoSCoordinator.kt | 39 +- .../player/qos/QoSEventsDispatcher.kt | 53 +-- .../player/qos/StartupTimesTracker.kt | 9 +- .../analytics/PlaybackSessionManagerTest.kt | 333 ++++++++++++++++++ .../player/qos/QoSEventsDispatcherTest.kt | 124 +------ .../player/qos/StartupTimesTrackerTest.kt | 11 +- 9 files changed, 638 insertions(+), 324 deletions(-) create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index cbdaf366c..8d0f153e6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -21,6 +21,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.StallTracker import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter @@ -65,6 +66,7 @@ class PillarboxExoPlayer internal constructor( } private val itemPillarboxDataTracker = CurrentMediaItemPillarboxDataTracker(this) private val analyticsTracker = AnalyticsMediaItemTracker(this, mediaItemTrackerProvider) + private val sessionManager = PlaybackSessionManager() private val window = Window() override var smoothSeekingEnabled: Boolean = false set(value) { @@ -124,10 +126,11 @@ class PillarboxExoPlayer internal constructor( QoSCoordinator( context = context, player = this, - eventsDispatcher = PillarboxEventsDispatcher(), + eventsDispatcher = PillarboxEventsDispatcher(sessionManager), startupTimesTracker = StartupTimesTracker(), metricsCollector = MetricsCollector(this), messageHandler = DummyQoSHandler, + sessionManager = sessionManager, coroutineContext = coroutineContext, ) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index 8534240e4..cdf56ec30 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -4,7 +4,20 @@ */ package ch.srgssr.pillarbox.player.analytics +import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.DiscontinuityReason +import androidx.media3.common.Player.MediaItemTransitionReason +import androidx.media3.common.Player.TimelineChangeReason +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.utils.DebugLogger +import ch.srgssr.pillarbox.player.utils.StringUtil import java.util.UUID /** @@ -12,8 +25,7 @@ import java.util.UUID * * @constructor Create empty Playback session manager */ -class PlaybackSessionManager : PillarboxAnalyticsListener { - +class PlaybackSessionManager { /** * - A session is linked to the period inside the timeline, see [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod]. * - A session is created when the player does something with a [MediaItem]. @@ -35,8 +47,6 @@ class PlaybackSessionManager : PillarboxAnalyticsListener { /** * Listener - * - * @constructor Create empty Listener */ interface Listener { /** @@ -61,13 +71,50 @@ class PlaybackSessionManager : PillarboxAnalyticsListener { fun onSessionFinished(session: Session) = Unit } + private val analyticsListener = SessionManagerAnalyticsListener() + private val listeners = mutableSetOf() + private val sessions = mutableMapOf() + private val window = Timeline.Window() + + private var currentSession: Session? = null + set(value) { + if (field != value) { + field?.let { session -> + notifyListeners { onSessionFinished(session) } + sessions.remove(session.periodUid) + } + field = value + field?.let { session -> + notifyListeners { onCurrentSession(session) } + } + } + } + + /** + * Register player + * + * @param player + */ + fun registerPlayer(player: ExoPlayer) { + player.addAnalyticsListener(analyticsListener) + } + + /** + * Unregister player + * + * @param player + */ + fun unregisterPlayer(player: ExoPlayer) { + player.removeAnalyticsListener(analyticsListener) + } + /** * Add listener * * @param listener */ fun addListener(listener: Listener) { - TODO("Implement addListener") + listeners.add(listener) } /** @@ -76,7 +123,7 @@ class PlaybackSessionManager : PillarboxAnalyticsListener { * @param listener */ fun removeListener(listener: Listener) { - TODO("implement removeListener") + listeners.remove(listener) } /** @@ -85,7 +132,7 @@ class PlaybackSessionManager : PillarboxAnalyticsListener { * @return */ fun getCurrentSession(): Session? { - TODO("implement getCurrentSession") + return currentSession } /** @@ -94,7 +141,183 @@ class PlaybackSessionManager : PillarboxAnalyticsListener { * @param sessionId * @return */ - fun getSessionFromId(sessionId: String): Session? { - TODO("implement getSessionFromId") + fun getSessionById(sessionId: String): Session? { + return sessions.values.find { it.sessionId == sessionId } + } + + /** + * Get session from event time + * + * @param eventTime + * @return + */ + fun getSessionFromEventTime(eventTime: AnalyticsListener.EventTime): Session? { + if (eventTime.timeline.isEmpty) { + return null + } + + eventTime.timeline.getWindow(eventTime.windowIndex, window) + + val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + + return sessions[periodUid] + } + + private inline fun notifyListeners(event: Listener.() -> Unit) { + listeners.toList() + .forEach { listener -> + listener.event() + } + } + + private inner class SessionManagerAnalyticsListener : PillarboxAnalyticsListener { + override fun onPositionDiscontinuity( + eventTime: AnalyticsListener.EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + @DiscontinuityReason reason: Int + ) { + val oldItemIndex = oldPosition.mediaItemIndex + val newItemIndex = newPosition.mediaItemIndex + + DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") + + if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { + currentSession = getOrCreateSession(eventTime) + } + } + + override fun onMediaItemTransition( + eventTime: AnalyticsListener.EventTime, + mediaItem: MediaItem?, + @MediaItemTransitionReason reason: Int, + ) { + DebugLogger.debug( + TAG, + "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}", + ) + + currentSession = mediaItem?.let { getOrCreateSession(eventTime) } + } + + override fun onTimelineChanged( + eventTime: AnalyticsListener.EventTime, + @TimelineChangeReason reason: Int, + ) { + val mediaItem = if (eventTime.timeline.isEmpty) { + MediaItem.EMPTY + } else { + eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem + } + + DebugLogger.debug(TAG, "onTimelineChanged reason = ${StringUtil.timelineChangeReasonString(reason)} ${mediaItem.mediaMetadata.title}") + + val timeline = eventTime.timeline + if (timeline.isEmpty) { + finishAllSessions() + return + } + + // Finish sessions that are no longer in the timeline + val currentSessions = sessions.values.toSet() + currentSessions.forEach { session -> + val periodUid = session.periodUid + val periodIndex = timeline.getIndexOfPeriod(periodUid) + if (periodIndex == C.INDEX_UNSET) { + if (session == currentSession) { + currentSession = null + } else { + notifyListeners { onSessionFinished(session) } + sessions.remove(session.periodUid) + } + } + } + } + + override fun onLoadStarted( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + ) { + getOrCreateSession(eventTime) + } + + override fun onPlayerError( + eventTime: AnalyticsListener.EventTime, + error: PlaybackException, + ) { + getOrCreateSession(eventTime) + } + + override fun onAudioPositionAdvancing( + eventTime: AnalyticsListener.EventTime, + playoutStartSystemTimeMs: Long, + ) { + getOrCreateSession(eventTime) + } + + override fun onRenderedFirstFrame( + eventTime: AnalyticsListener.EventTime, + output: Any, + renderTimeMs: Long, + ) { + getOrCreateSession(eventTime) + } + + override fun onStallChanged( + eventTime: AnalyticsListener.EventTime, + isStall: Boolean, + ) { + getOrCreateSession(eventTime) + } + + override fun onIsPlayingChanged( + eventTime: AnalyticsListener.EventTime, + isPlaying: Boolean, + ) { + getOrCreateSession(eventTime) + } + + override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { + DebugLogger.debug(TAG, "onPlayerReleased") + finishAllSessions() + } + + private fun getOrCreateSession(eventTime: AnalyticsListener.EventTime): Session? { + if (eventTime.timeline.isEmpty) { + return null + } + + eventTime.timeline.getWindow(eventTime.windowIndex, window) + + val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + var session = sessions[periodUid] + if (session == null) { + val newSession = Session(periodUid, window.mediaItem) + sessions[periodUid] = newSession + notifyListeners { onSessionCreated(newSession) } + + if (currentSession == null) { + currentSession = newSession + } + + session = newSession + } + + return session + } + + private fun finishAllSessions() { + currentSession = null + + sessions.values.forEach { session -> + notifyListeners { onSessionFinished(session) } + } + sessions.clear() + } + } + + private companion object { + private const val TAG = "PlaybackSessionManager" } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt index 9fad02278..a29a8e6e9 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt @@ -4,30 +4,25 @@ */ package ch.srgssr.pillarbox.player.qos -import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT import androidx.media3.common.Player.DiscontinuityReason -import androidx.media3.common.Player.MediaItemTransitionReason -import androidx.media3.common.Player.TimelineChangeReason -import androidx.media3.common.Timeline import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Listener -import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Session import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.StringUtil /** * Pillarbox provided implementation of [QoSEventsDispatcher]. */ -class PillarboxEventsDispatcher : QoSEventsDispatcher { +class PillarboxEventsDispatcher( + private val sessionManager: PlaybackSessionManager, +) : QoSEventsDispatcher { private val analyticsListener = EventsDispatcherAnalyticsListener() private val listeners = mutableSetOf() @@ -55,23 +50,6 @@ class PillarboxEventsDispatcher : QoSEventsDispatcher { } private inner class EventsDispatcherAnalyticsListener : PillarboxAnalyticsListener { - private val sessions = mutableMapOf() - private val window = Timeline.Window() - - private var currentSession: Session? = null - set(value) { - if (field != value) { - field?.let { session -> - notifyListeners { onSessionFinished(session) } - sessions.remove(session.periodUid) - } - field = value - field?.let { session -> - notifyListeners { onCurrentSession(session) } - } - } - } - override fun onPositionDiscontinuity( eventTime: EventTime, oldPosition: Player.PositionInfo, @@ -83,87 +61,24 @@ class PillarboxEventsDispatcher : QoSEventsDispatcher { DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") - if (oldItemIndex != newItemIndex && !eventTime.timeline.isEmpty) { - currentSession = getOrCreateSession(eventTime) - } if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK || reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { - currentSession?.let { - notifyListeners { - onSeek(it) - } - } - } - } - - override fun onMediaItemTransition( - eventTime: EventTime, - mediaItem: MediaItem?, - @MediaItemTransitionReason reason: Int, - ) { - DebugLogger.debug( - TAG, - "onMediaItemTransition reason = ${StringUtil.mediaItemTransitionReasonString(reason)} ${mediaItem?.mediaMetadata?.title}", - ) - - currentSession = mediaItem?.let { getOrCreateSession(eventTime) } - } + val session = sessionManager.getCurrentSession() ?: return - override fun onPlayerError(eventTime: EventTime, error: PlaybackException) { - val session = getOrCreateSession(eventTime) - session?.let { - notifyListeners { - onError(session) - } + notifyListeners { onSeek(session) } } } - override fun onTimelineChanged( - eventTime: EventTime, - @TimelineChangeReason reason: Int, - ) { - val mediaItem = if (eventTime.timeline.isEmpty) { - MediaItem.EMPTY - } else { - eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem - } - - DebugLogger.debug(TAG, "onTimelineChanged reason = ${StringUtil.timelineChangeReasonString(reason)} ${mediaItem.mediaMetadata.title}") - - val timeline = eventTime.timeline - if (timeline.isEmpty) { - finishAllSessions() - return - } - - // Finish sessions that are no longer in the timeline - val currentSessions = sessions.values.toSet() - currentSessions.forEach { session -> - val periodUid = session.periodUid - val periodIndex = timeline.getIndexOfPeriod(periodUid) - if (periodIndex == C.INDEX_UNSET) { - if (session == currentSession) { - currentSession = null - } else { - notifyListeners { onSessionFinished(session) } - sessions.remove(session.periodUid) - } - } - } - } + override fun onPlayerError(eventTime: EventTime, error: PlaybackException) { + val session = sessionManager.getSessionFromEventTime(eventTime) ?: return - override fun onLoadStarted( - eventTime: EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - ) { - getOrCreateSession(eventTime) + notifyListeners { onError(session) } } override fun onAudioPositionAdvancing( eventTime: EventTime, playoutStartSystemTimeMs: Long, ) { - val session = getOrCreateSession(eventTime) ?: return + val session = sessionManager.getSessionFromEventTime(eventTime) ?: return notifyListeners { onMediaStart(session) } } @@ -173,60 +88,28 @@ class PillarboxEventsDispatcher : QoSEventsDispatcher { output: Any, renderTimeMs: Long, ) { - val session = getOrCreateSession(eventTime) ?: return + val session = sessionManager.getSessionFromEventTime(eventTime) ?: return notifyListeners { onMediaStart(session) } } override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { - val session = getOrCreateSession(eventTime) ?: return if (isStall) { + val session = sessionManager.getSessionFromEventTime(eventTime) ?: return + notifyListeners { onStall(session) } } } override fun onPlayerReleased(eventTime: EventTime) { DebugLogger.debug(TAG, "onPlayerReleased") - finishAllSessions() notifyListeners { onPlayerReleased() } } override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { - val session = getOrCreateSession(eventTime) ?: return - notifyListeners { onIsPlaying(session, isPlaying) } - } - - private fun getOrCreateSession(eventTime: EventTime): Session? { - if (eventTime.timeline.isEmpty) { - return null - } - - eventTime.timeline.getWindow(eventTime.windowIndex, window) + val session = sessionManager.getSessionFromEventTime(eventTime) ?: return - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) - var session = sessions[periodUid] - if (session == null) { - val newSession = Session(periodUid, window.mediaItem) - sessions[periodUid] = newSession - notifyListeners { onSessionCreated(newSession) } - - if (currentSession == null) { - currentSession = newSession - } - - session = newSession - } - - return session - } - - private fun finishAllSessions() { - currentSession = null - - sessions.values.forEach { session -> - notifyListeners { onSessionFinished(session) } - } - sessions.clear() + notifyListeners { onIsPlaying(session, isPlaying) } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 68440bc74..65702fcda 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -12,6 +12,7 @@ import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.PlaybackStats import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat @@ -25,6 +26,7 @@ internal class QoSCoordinator( private val startupTimesTracker: StartupTimesTracker, private val metricsCollector: MetricsCollector, private val messageHandler: QoSMessageHandler, + private val sessionManager: PlaybackSessionManager, coroutineContext: CoroutineContext, ) : PillarboxAnalyticsListener { private val heartbeat = Heartbeat( @@ -38,14 +40,19 @@ internal class QoSCoordinator( ) private var url: String = "" - private val sessions = mutableMapOf() - private var currentSession: QoSEventsDispatcher.Session? = null + private var currentSession: PlaybackSessionManager.Session? = null init { + val eventsDispatcherListener = EventsDispatcherListener() + eventsDispatcher.registerPlayer(player) - eventsDispatcher.addListener(EventsDispatcherListener()) + eventsDispatcher.addListener(eventsDispatcherListener) eventsDispatcher.addListener(startupTimesTracker) + sessionManager.registerPlayer(player) + sessionManager.addListener(eventsDispatcherListener) + sessionManager.addListener(startupTimesTracker) + player.addAnalyticsListener(startupTimesTracker) player.addAnalyticsListener(metricsCollector) player.addAnalyticsListener(this) @@ -66,7 +73,7 @@ internal class QoSCoordinator( private fun sendEvent( eventName: String, - session: QoSEventsDispatcher.Session, + session: PlaybackSessionManager.Session, data: Any? = null, ) { val message = QoSMessage( @@ -93,19 +100,19 @@ internal class QoSCoordinator( ) } - private inner class EventsDispatcherListener : QoSEventsDispatcher.Listener { + private inner class EventsDispatcherListener : PlaybackSessionManager.Listener, QoSEventsDispatcher.Listener { - override fun onCurrentSession(session: QoSEventsDispatcher.Session) { + override fun onCurrentSession(session: PlaybackSessionManager.Session) { currentSession = session } - override fun onSessionFinished(session: QoSEventsDispatcher.Session) { + override fun onSessionFinished(session: PlaybackSessionManager.Session) { heartbeat.stop() sendEvent("END", session) currentSession = null } - override fun onMediaStart(session: QoSEventsDispatcher.Session) { + override fun onMediaStart(session: PlaybackSessionManager.Session) { val startupTimes = startupTimesTracker.consumeStartupTimes(session.sessionId) ?: return heartbeat.start(restart = false) @@ -114,7 +121,7 @@ internal class QoSCoordinator( } override fun onIsPlaying( - session: QoSEventsDispatcher.Session, + session: PlaybackSessionManager.Session, isPlaying: Boolean, ) { if (isPlaying) { @@ -124,16 +131,16 @@ internal class QoSCoordinator( } } - override fun onSeek(session: QoSEventsDispatcher.Session) { + override fun onSeek(session: PlaybackSessionManager.Session) { sendEvent("SEEK", session) } - override fun onStall(session: QoSEventsDispatcher.Session) { + override fun onStall(session: PlaybackSessionManager.Session) { sendEvent("STALL", session) } - override fun onError(session: QoSEventsDispatcher.Session) { - if (!sessions.containsKey(session.sessionId)) { + override fun onError(session: PlaybackSessionManager.Session) { + if (sessionManager.getSessionById(session.sessionId) == null) { sendStartEvent(session, QoSSessionTimings.Zero) } @@ -155,13 +162,17 @@ internal class QoSCoordinator( eventsDispatcher.removeListener(this) eventsDispatcher.removeListener(startupTimesTracker) + sessionManager.unregisterPlayer(player) + sessionManager.removeListener(this) + sessionManager.removeListener(startupTimesTracker) + player.removeAnalyticsListener(startupTimesTracker) player.removeAnalyticsListener(metricsCollector) player.removeAnalyticsListener(this@QoSCoordinator) } private fun sendStartEvent( - session: QoSEventsDispatcher.Session, + session: PlaybackSessionManager.Session, timings: QoSSessionTimings, ) { sendEvent( diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt index e5d3ff76a..d1ddb0a3c 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt @@ -4,57 +4,23 @@ */ package ch.srgssr.pillarbox.player.qos -import androidx.media3.common.MediaItem import androidx.media3.exoplayer.ExoPlayer -import java.util.UUID +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager /** * Events dispatcher that notifies when specific events happen (related to a session, media playback, ...). */ interface QoSEventsDispatcher { - /** - * - A session is linked to the period inside the timeline, see [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod]. - * - A session is created when the player does something with a [MediaItem]. - * - A session is current if the media item associated with the session is the current [MediaItem]. - * - A session is finished when it is no longer the current session, or when the session is removed from the player. - * - * @property periodUid The period id from [Timeline.getUidOfPeriod][androidx.media3.common.Timeline.getUidOfPeriod] for [mediaItem]. - * @property mediaItem The [MediaItem] linked to the session. - */ - data class Session( - val periodUid: Any, - val mediaItem: MediaItem, - ) { - /** - * Unique session id. - */ - val sessionId = UUID.randomUUID().toString() - } - /** * Listener to be notified for every event dispatched by [QoSEventsDispatcher]. */ interface Listener { - /** - * On session created - * - * @param session - */ - fun onSessionCreated(session: Session) = Unit - - /** - * On current session - * - * @param session - */ - fun onCurrentSession(session: Session) = Unit - /** * On media start * * @param session */ - fun onMediaStart(session: Session) = Unit + fun onMediaStart(session: PlaybackSessionManager.Session) = Unit /** * On is playing @@ -63,7 +29,7 @@ interface QoSEventsDispatcher { * @param isPlaying */ fun onIsPlaying( - session: Session, + session: PlaybackSessionManager.Session, isPlaying: Boolean, ) = Unit @@ -72,28 +38,21 @@ interface QoSEventsDispatcher { * * @param session */ - fun onSeek(session: Session) = Unit + fun onSeek(session: PlaybackSessionManager.Session) = Unit /** * On stall * * @param session */ - fun onStall(session: Session) = Unit + fun onStall(session: PlaybackSessionManager.Session) = Unit /** * On error * * @param session */ - fun onError(session: Session) = Unit - - /** - * On session finished - * - * @param session - */ - fun onSessionFinished(session: Session) = Unit + fun onError(session: PlaybackSessionManager.Session) = Unit /** * On player released diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt index cbf55a4c7..3a13480a6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt @@ -9,10 +9,11 @@ import androidx.media3.common.Timeline import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.source.PillarboxMediaSource import kotlin.time.Duration.Companion.milliseconds -internal class StartupTimesTracker : AnalyticsListener, QoSEventsDispatcher.Listener { +internal class StartupTimesTracker : AnalyticsListener, PlaybackSessionManager.Listener, QoSEventsDispatcher.Listener { private val loadingSessions = mutableSetOf() private val periodUidToSessionId = mutableMapOf() private val currentSessionToMediaStart = mutableMapOf() @@ -34,17 +35,17 @@ internal class StartupTimesTracker : AnalyticsListener, QoSEventsDispatcher.List return null } - override fun onSessionCreated(session: QoSEventsDispatcher.Session) { + override fun onSessionCreated(session: PlaybackSessionManager.Session) { loadingSessions.add(session.sessionId) periodUidToSessionId[session.periodUid] = session.sessionId qosSessionsTimings[session.sessionId] = QoSSessionTimings.Zero } - override fun onCurrentSession(session: QoSEventsDispatcher.Session) { + override fun onCurrentSession(session: PlaybackSessionManager.Session) { currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() } - override fun onSessionFinished(session: QoSEventsDispatcher.Session) { + override fun onSessionFinished(session: PlaybackSessionManager.Session) { loadingSessions.remove(session.sessionId) periodUidToSessionId.remove(session.periodUid) qosSessionsTimings.remove(session.sessionId) diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt new file mode 100644 index 000000000..433e63b0c --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import android.content.Context +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +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 io.mockk.clearAllMocks +import io.mockk.clearMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class PlaybackSessionManagerTest { + private lateinit var clock: FakeClock + private lateinit var player: ExoPlayer + private lateinit var sessionManager: PlaybackSessionManager + private lateinit var sessionManagerListener: PlaybackSessionManager.Listener + + @BeforeTest + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + + clock = FakeClock(true) + sessionManagerListener = mockk(relaxed = true) + player = ExoPlayer.Builder(context) + .setClock(clock) + .build() + .apply { + prepare() + } + + sessionManager = PlaybackSessionManager().apply { + registerPlayer(player) + addListener(sessionManagerListener) + } + + clearMocks(sessionManagerListener) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + sessionManager.unregisterPlayer(player) + sessionManager.removeListener(sessionManagerListener) + player.release() + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `get session single media item`() { + val mediaItem = MediaItem.fromUri(VOD1) + + assertNull(sessionManager.getCurrentSession()) + assertNull(sessionManager.getSessionById("some-invalid-session-id")) + + player.setMediaItem(mediaItem) + player.play() + + TestPlayerRunHelper.playUntilStartOfMediaItem(player, 0) + + val sessionSlot = slot() + + verify { + sessionManagerListener.onSessionCreated(capture(sessionSlot)) + } + + val session = sessionManager.getCurrentSession() + + assertNotNull(session) + assertEquals(sessionSlot.captured.sessionId, session.sessionId) + assertEquals(session, sessionManager.getSessionById(session.sessionId)) + } + + @Test + fun `get session multiple media items`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + assertNull(sessionManager.getCurrentSession()) + assertNull(sessionManager.getSessionById("some-invalid-session-id")) + + player.setMediaItems(mediaItems) + player.play() + + TestPlayerRunHelper.playUntilStartOfMediaItem(player, 2) + + val onSessionCreated = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(onSessionCreated)) + } + + val session = sessionManager.getCurrentSession() + + assertEquals(3, onSessionCreated.distinctBy { it.sessionId }.size) + assertNotNull(session) + assertEquals(onSessionCreated[1].sessionId, session.sessionId) + assertEquals(session, sessionManager.getSessionById(session.sessionId)) + } + + @Test + fun `play single media item`() { + val mediaItem = MediaItem.fromUri(VOD1) + + player.setMediaItem(mediaItem) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val onSessionCreated = mutableListOf() + val onCurrentSession = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(onSessionCreated)) + sessionManagerListener.onCurrentSession(capture(onCurrentSession)) + } + confirmVerified(sessionManagerListener) + + assertEquals(1, onSessionCreated.size) + assertEquals(1, onCurrentSession.size) + + assertEquals(1, onSessionCreated.distinctBy { it.sessionId }.size) + assertEquals(1, onCurrentSession.distinctBy { it.sessionId }.size) + + assertEquals(listOf(mediaItem), onSessionCreated.map { it.mediaItem }) + assertEquals(listOf(mediaItem), onCurrentSession.map { it.mediaItem }) + } + + @Test + fun `play single media item, remove media item`() { + val mediaItem = MediaItem.fromUri(VOD1) + + player.setMediaItem(mediaItem) + player.play() + player.removeMediaItem(player.currentMediaItemIndex) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + val sessions = mutableListOf() + + verifyOrder { + sessionManagerListener.onSessionCreated(capture(sessions)) + sessionManagerListener.onCurrentSession(capture(sessions)) + sessionManagerListener.onSessionFinished(capture(sessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, sessions.size) + assertEquals(1, sessions.distinctBy { it.sessionId }.size) + assertTrue(sessions.all { it.mediaItem == mediaItem }) + } + + @Test + fun `play multiple media items`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val onSessionCreated = mutableListOf() + val onCurrentSession = mutableListOf() + val onSessionFinished = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(onSessionCreated)) + sessionManagerListener.onCurrentSession(capture(onCurrentSession)) + sessionManagerListener.onSessionFinished(capture(onSessionFinished)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, onSessionCreated.size) + assertEquals(3, onCurrentSession.size) + assertEquals(3, onSessionFinished.size) + + assertEquals(3, onSessionCreated.distinctBy { it.sessionId }.size) + assertEquals(3, onCurrentSession.distinctBy { it.sessionId }.size) + assertEquals(3, onSessionFinished.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, onSessionCreated.map { it.mediaItem }) + assertEquals(mediaItems, onCurrentSession.map { it.mediaItem }) + assertEquals(mediaItems, onSessionFinished.map { it.mediaItem }) + } + + @Test + fun `play multiple media items, remove upcoming media item`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + val expectedMediaItems = listOf(mediaItems[0], mediaItems[2]) + + player.setMediaItems(mediaItems) + player.play() + player.removeMediaItem(player.currentMediaItemIndex + 1) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(2, createdSessions.size) + assertEquals(2, currentSessions.size) + assertEquals(2, finishedSessions.size) + + assertEquals(2, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(2, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(2, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(expectedMediaItems, createdSessions.map { it.mediaItem }) + assertEquals(expectedMediaItems, currentSessions.map { it.mediaItem }) + assertEquals(expectedMediaItems, finishedSessions.map { it.mediaItem }) + } + + @Test + fun `play multiple media items, remove current media item`() { + val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + player.removeMediaItem(player.currentMediaItemIndex) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, createdSessions.size) + assertEquals(3, currentSessions.size) + assertEquals(3, finishedSessions.size) + + assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, createdSessions.map { it.mediaItem }) + assertEquals(mediaItems, currentSessions.map { it.mediaItem }) + assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) + } + + @Test + fun `play multiple same media items create multiple sessions`() { + val mediaItems = listOf(VOD1, VOD1, VOD3).map { MediaItem.fromUri(it) } + + player.setMediaItems(mediaItems) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val createdSessions = mutableListOf() + val currentSessions = mutableListOf() + val finishedSessions = mutableListOf() + + verify { + sessionManagerListener.onSessionCreated(capture(createdSessions)) + sessionManagerListener.onCurrentSession(capture(currentSessions)) + sessionManagerListener.onSessionFinished(capture(finishedSessions)) + } + confirmVerified(sessionManagerListener) + + assertEquals(3, createdSessions.size) + assertEquals(3, currentSessions.size) + assertEquals(3, finishedSessions.size) + + assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) + assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) + assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) + + assertEquals(mediaItems, createdSessions.map { it.mediaItem }) + assertEquals(mediaItems, currentSessions.map { it.mediaItem }) + assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) + } + + private companion object { + private const val VOD1 = "https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8" + private const val VOD2 = "https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8" + private const val VOD3 = "https://rts-vod-amd.akamaized.net/ww/13444466/2787e520-412f-35fb-83d7-8dbb31b5c684/master.m3u8" + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt index cf02f8848..26bfab447 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt @@ -13,19 +13,18 @@ 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.analytics.PlaybackSessionManager import io.mockk.clearAllMocks import io.mockk.clearMocks import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.verify -import io.mockk.verifyOrder import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class QoSEventsDispatcherTest { @@ -46,7 +45,11 @@ class QoSEventsDispatcherTest { prepare() } - PillarboxEventsDispatcher().apply { + val sessionManager = PlaybackSessionManager().apply { + registerPlayer(player) + } + + PillarboxEventsDispatcher(sessionManager).apply { registerPlayer(player) addListener(eventsDispatcherListener) } @@ -70,55 +73,19 @@ class QoSEventsDispatcherTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - val onSessionCreated = mutableListOf() - val onCurrentSession = mutableListOf() - val onIsPlayingSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() val onIsPlayingValue = mutableListOf() verify { - eventsDispatcherListener.onSessionCreated(capture(onSessionCreated)) - eventsDispatcherListener.onCurrentSession(capture(onCurrentSession)) eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } confirmVerified(eventsDispatcherListener) - assertEquals(1, onSessionCreated.size) - assertEquals(1, onCurrentSession.size) assertEquals(2, onIsPlayingValue.size) - - assertEquals(1, onSessionCreated.distinctBy { it.sessionId }.size) - assertEquals(1, onCurrentSession.distinctBy { it.sessionId }.size) assertEquals(1, onIsPlayingSessions.distinctBy { it.sessionId }.size) - - assertEquals(listOf(mediaItem), onSessionCreated.map { it.mediaItem }) - assertEquals(listOf(mediaItem), onCurrentSession.map { it.mediaItem }) assertEquals(listOf(true, false), onIsPlayingValue) } - @Test - fun `play single media item, remove media item`() { - val mediaItem = MediaItem.fromUri(VOD1) - - player.setMediaItem(mediaItem) - player.play() - player.removeMediaItem(player.currentMediaItemIndex) - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - val sessions = mutableListOf() - - verifyOrder { - eventsDispatcherListener.onSessionCreated(capture(sessions)) - eventsDispatcherListener.onCurrentSession(capture(sessions)) - eventsDispatcherListener.onSessionFinished(capture(sessions)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(3, sessions.size) - assertEquals(1, sessions.distinctBy { it.sessionId }.size) - assertTrue(sessions.all { it.mediaItem == mediaItem }) - } - @Test fun `play multiple media items`() { val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } @@ -133,40 +100,22 @@ class QoSEventsDispatcherTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val onSessionCreated = mutableListOf() - val onCurrentSession = mutableListOf() - val onSessionFinished = mutableListOf() - val onIsPlayingSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() val onIsPlayingValue = mutableListOf() verify { - eventsDispatcherListener.onSessionCreated(capture(onSessionCreated)) - eventsDispatcherListener.onCurrentSession(capture(onCurrentSession)) - eventsDispatcherListener.onSessionFinished(capture(onSessionFinished)) eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } confirmVerified(eventsDispatcherListener) - assertEquals(3, onSessionCreated.size) - assertEquals(3, onCurrentSession.size) - assertEquals(3, onSessionFinished.size) assertEquals(6, onIsPlayingValue.size) - - assertEquals(3, onSessionCreated.distinctBy { it.sessionId }.size) - assertEquals(3, onCurrentSession.distinctBy { it.sessionId }.size) - assertEquals(3, onSessionFinished.distinctBy { it.sessionId }.size) assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) - - assertEquals(mediaItems, onSessionCreated.map { it.mediaItem }) - assertEquals(mediaItems, onCurrentSession.map { it.mediaItem }) - assertEquals(mediaItems, onSessionFinished.map { it.mediaItem }) assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) } @Test fun `play multiple media items, remove upcoming media item`() { val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } - val expectedMediaItems = listOf(mediaItems[0], mediaItems[2]) player.setMediaItems(mediaItems) player.play() @@ -179,33 +128,16 @@ class QoSEventsDispatcherTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() - val onIsPlayingSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() val onIsPlayingValue = mutableListOf() verify { - eventsDispatcherListener.onSessionCreated(capture(createdSessions)) - eventsDispatcherListener.onCurrentSession(capture(currentSessions)) - eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } confirmVerified(eventsDispatcherListener) - assertEquals(2, createdSessions.size) - assertEquals(2, currentSessions.size) - assertEquals(2, finishedSessions.size) assertEquals(4, onIsPlayingValue.size) - - assertEquals(2, createdSessions.distinctBy { it.sessionId }.size) - assertEquals(2, currentSessions.distinctBy { it.sessionId }.size) - assertEquals(2, finishedSessions.distinctBy { it.sessionId }.size) assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) - - assertEquals(expectedMediaItems, createdSessions.map { it.mediaItem }) - assertEquals(expectedMediaItems, currentSessions.map { it.mediaItem }) - assertEquals(expectedMediaItems, finishedSessions.map { it.mediaItem }) assertEquals(listOf(true, false, true, false), onIsPlayingValue) } @@ -224,33 +156,16 @@ class QoSEventsDispatcherTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() - val onIsPlayingSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() val onIsPlayingValue = mutableListOf() verify { - eventsDispatcherListener.onSessionCreated(capture(createdSessions)) - eventsDispatcherListener.onCurrentSession(capture(currentSessions)) - eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } confirmVerified(eventsDispatcherListener) - assertEquals(3, createdSessions.size) - assertEquals(3, currentSessions.size) - assertEquals(3, finishedSessions.size) assertEquals(4, onIsPlayingValue.size) - - assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) - assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) - assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) - - assertEquals(mediaItems, createdSessions.map { it.mediaItem }) - assertEquals(mediaItems, currentSessions.map { it.mediaItem }) - assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) assertEquals(listOf(true, false, true, false), onIsPlayingValue) } @@ -268,33 +183,16 @@ class QoSEventsDispatcherTest { TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - val createdSessions = mutableListOf() - val currentSessions = mutableListOf() - val finishedSessions = mutableListOf() - val onIsPlayingSessions = mutableListOf() + val onIsPlayingSessions = mutableListOf() val onIsPlayingValue = mutableListOf() verify { - eventsDispatcherListener.onSessionCreated(capture(createdSessions)) - eventsDispatcherListener.onCurrentSession(capture(currentSessions)) - eventsDispatcherListener.onSessionFinished(capture(finishedSessions)) eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) } confirmVerified(eventsDispatcherListener) - assertEquals(3, createdSessions.size) - assertEquals(3, currentSessions.size) - assertEquals(3, finishedSessions.size) assertEquals(6, onIsPlayingValue.size) - - assertEquals(3, createdSessions.distinctBy { it.sessionId }.size) - assertEquals(3, currentSessions.distinctBy { it.sessionId }.size) - assertEquals(3, finishedSessions.distinctBy { it.sessionId }.size) assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) - - assertEquals(mediaItems, createdSessions.map { it.mediaItem }) - assertEquals(mediaItems, currentSessions.map { it.mediaItem }) - assertEquals(mediaItems, finishedSessions.map { it.mediaItem }) assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt index a2da6937c..a12eb39f5 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt @@ -13,6 +13,7 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import ch.srgssr.pillarbox.player.PillarboxExoPlayer import ch.srgssr.pillarbox.player.analytics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner.Parameters @@ -71,9 +72,10 @@ class StartupTimesTrackerTest( coroutineContext = coroutineContext, ).apply { val mediaItems = mediaUrls.map(MediaItem::fromUri) - val eventsDispatcher = PillarboxEventsDispatcher() - eventsDispatcher.addListener(object : QoSEventsDispatcher.Listener { - override fun onSessionCreated(session: QoSEventsDispatcher.Session) { + val sessionManager = PlaybackSessionManager() + sessionManager.registerPlayer(this) + sessionManager.addListener(object : PlaybackSessionManager.Listener { + override fun onSessionCreated(session: PlaybackSessionManager.Session) { sessionId = session.sessionId } }) @@ -81,10 +83,11 @@ class StartupTimesTrackerTest( QoSCoordinator( context = context, player = this, - eventsDispatcher = eventsDispatcher, + eventsDispatcher = PillarboxEventsDispatcher(sessionManager), startupTimesTracker = startupTimesTracker, metricsCollector = MetricsCollector(this), messageHandler = DummyQoSHandler, + sessionManager = sessionManager, coroutineContext = coroutineContext, ) From 62b0ec7bb3c9db9ec9fbf7c228f8f2c79bf6f8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Thu, 18 Jul 2024 08:37:08 +0200 Subject: [PATCH 09/17] 631 metrics collector (#638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller Co-authored-by: Gaëtan Muller --- .../commandersact/CommandersActStreaming.kt | 2 +- .../CommandersActStreamingTest.kt | 1 + .../CommandersActTrackerIntegrationTest.kt | 8 +- .../pillarbox/player/PillarboxExoPlayer.kt | 52 ++- .../player/analytics/MetricsCollector.kt | 135 -------- .../analytics/PlaybackSessionManager.kt | 30 +- .../player/analytics/PlaybackStats.kt | 26 -- .../player/analytics/StallTracker.kt | 130 ------- .../player/analytics}/TotalPlaytimeCounter.kt | 13 +- .../player/analytics/extension/EventTime.kt | 13 + .../player/analytics/metrics/LoadingTimes.kt | 44 +++ .../analytics/metrics/MetricsCollector.kt | 318 ++++++++++++++++++ .../analytics/metrics/PlaybackMetrics.kt | 49 +++ .../player/qos/PillarboxEventsDispatcher.kt | 6 +- .../pillarbox/player/qos/QoSCoordinator.kt | 92 ++--- .../srgssr/pillarbox/player/qos/QoSSession.kt | 2 +- .../pillarbox/player/qos/QoSSessionTimings.kt | 20 +- .../player/qos/StartupTimesTracker.kt | 85 ----- .../player}/TotalPlaytimeCounterTest.kt | 3 +- .../player/analytics/LoadingTimesTest.kt | 125 +++++++ .../player/analytics/MetricsCollectorTest.kt | 148 ++++++++ .../analytics/PlaybackSessionManagerTest.kt | 4 +- .../player/qos/QoSEventsDispatcherTest.kt | 2 +- .../pillarbox/player/qos/QoSSessionTest.kt | 18 +- .../player/qos/QoSSessionTimingsTest.kt | 13 +- .../player/qos/StartupTimesTrackerTest.kt | 122 ------- 26 files changed, 842 insertions(+), 619 deletions(-) delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt rename {pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker => pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics}/TotalPlaytimeCounter.kt (82%) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt rename {pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker => pillarbox-player/src/test/java/ch/srgssr/pillarbox/player}/TotalPlaytimeCounterTest.kt (92%) create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt index 77745dbe6..4ab786c0e 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt @@ -12,7 +12,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent -import ch.srgssr.pillarbox.core.business.tracker.TotalPlaytimeCounter +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.extension.hasAccessibilityRoles import ch.srgssr.pillarbox.player.extension.isForced import ch.srgssr.pillarbox.player.tracks.audioTracks diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt index a27181415..5542a8bfe 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt @@ -263,6 +263,7 @@ class CommandersActStreamingTest { return mockk { val player = this + every { player.playWhenReady } returns true every { player.isPlaying } returns isPlaying every { player.currentPosition } returns currentPosition every { player.isCurrentMediaItemLive } returns isCurrentMediaItemLive diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index 8b33f69e8..1ce392351 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -30,6 +30,7 @@ import ch.srgssr.pillarbox.core.business.utils.LocalMediaCompositionWithFallback import ch.srgssr.pillarbox.player.test.utils.TestPillarboxRunHelper import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import io.mockk.Called +import io.mockk.clearAllMocks import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.slot @@ -97,10 +98,9 @@ class CommandersActTrackerIntegrationTest { @AfterTest @OptIn(ExperimentalCoroutinesApi::class) fun tearDown() { + clearAllMocks() player.release() - shadowOf(Looper.getMainLooper()).idle() - Dispatchers.resetMain() } @@ -591,8 +591,7 @@ class CommandersActTrackerIntegrationTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) - fun `player pause, seeking and pause`() = runTest(testDispatcher) { + fun `player pause, seeking and pause`() { player.setMediaItem(SRGMediaItemBuilder(URN_NOT_LIVE_VIDEO).build()) player.prepare() player.playWhenReady = false @@ -600,7 +599,6 @@ class CommandersActTrackerIntegrationTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) clock.advanceTime(2.seconds.inWholeMilliseconds) - advanceTimeBy(2.seconds) TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 8d0f153e6..d5fe10ad1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -19,10 +19,10 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter -import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.StallTracker +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter import ch.srgssr.pillarbox.player.asset.timeRange.Credit @@ -32,7 +32,6 @@ import ch.srgssr.pillarbox.player.extension.setSeekIncrements import ch.srgssr.pillarbox.player.qos.DummyQoSHandler import ch.srgssr.pillarbox.player.qos.PillarboxEventsDispatcher import ch.srgssr.pillarbox.player.qos.QoSCoordinator -import ch.srgssr.pillarbox.player.qos.StartupTimesTracker import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemPillarboxDataTracker @@ -46,13 +45,12 @@ import kotlin.coroutines.CoroutineContext /** * Pillarbox player * - * @param context - * @param coroutineContext - * @param exoPlayer - * @param mediaItemTrackerProvider - * @param analyticsCollector - * - * @constructor + * @param context The context. + * @param coroutineContext The [CoroutineContext]. + * @param exoPlayer The underlying player. + * @param mediaItemTrackerProvider The [MediaItemTrackerProvider]. + * @param analyticsCollector The [PillarboxAnalyticsCollector]. + * @param metricsCollector The [MetricsCollector]. */ class PillarboxExoPlayer internal constructor( context: Context, @@ -60,13 +58,14 @@ class PillarboxExoPlayer internal constructor( private val exoPlayer: ExoPlayer, mediaItemTrackerProvider: MediaItemTrackerProvider, analyticsCollector: PillarboxAnalyticsCollector, + private val metricsCollector: MetricsCollector = MetricsCollector(), ) : PillarboxPlayer, ExoPlayer by exoPlayer { private val listeners = ListenerSet(applicationLooper, clock) { listener, flags -> listener.onEvents(this, Player.Events(flags)) } private val itemPillarboxDataTracker = CurrentMediaItemPillarboxDataTracker(this) private val analyticsTracker = AnalyticsMediaItemTracker(this, mediaItemTrackerProvider) - private val sessionManager = PlaybackSessionManager() + internal val sessionManager = PlaybackSessionManager() private val window = Window() override var smoothSeekingEnabled: Boolean = false set(value) { @@ -123,12 +122,13 @@ class PillarboxExoPlayer internal constructor( ) init { + sessionManager.setPlayer(this) + metricsCollector.setPlayer(this) QoSCoordinator( context = context, player = this, eventsDispatcher = PillarboxEventsDispatcher(sessionManager), - startupTimesTracker = StartupTimesTracker(), - metricsCollector = MetricsCollector(this), + metricsCollector = metricsCollector, messageHandler = DummyQoSHandler, sessionManager = sessionManager, coroutineContext = coroutineContext, @@ -141,7 +141,6 @@ class PillarboxExoPlayer internal constructor( if (BuildConfig.DEBUG) { addAnalyticsListener(PillarboxEventLogger()) } - addAnalyticsListener(StallTracker()) } constructor( @@ -170,6 +169,7 @@ class PillarboxExoPlayer internal constructor( clock: Clock, coroutineContext: CoroutineContext, analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), + metricsCollector: MetricsCollector = MetricsCollector() ) : this( context, coroutineContext, @@ -197,9 +197,31 @@ class PillarboxExoPlayer internal constructor( .setDeviceVolumeControlEnabled(true) // allow player to control device volume .build(), mediaItemTrackerProvider = mediaItemTrackerProvider, - analyticsCollector = analyticsCollector + analyticsCollector = analyticsCollector, + metricsCollector = metricsCollector, ) + /** + * Get current metrics + * @return `null` if there is no current metrics. + */ + fun getCurrentMetrics(): PlaybackMetrics? { + return metricsCollector.getCurrentMetrics() + } + + /** + * Get metrics for item [index] + * + * @param index The index in the timeline. + * @return `null` if there are no metrics. + */ + fun getMetricsFor(index: Int): PlaybackMetrics? { + if (currentTimeline.isEmpty) return null + currentTimeline.getWindow(index, window) + val periodUid = currentTimeline.getUidOfPeriod(window.firstPeriodIndex) + return sessionManager.getSessionFromPeriodUid(periodUid)?.let { metricsCollector.getMetricsForSession(it) } + } + override fun addListener(listener: Player.Listener) { exoPlayer.addListener(listener) if (listener is PillarboxPlayer.Listener) { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt deleted file mode 100644 index 0c271984a..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.analytics - -import androidx.media3.common.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.DecoderCounters -import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.analytics.AnalyticsListener -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Playback stats metrics - * Compute playback stats metrics likes stalls, playtime, bitrate, etc.. - */ -class MetricsCollector(private val player: ExoPlayer) : PillarboxAnalyticsListener { - - private var stallCount = 0 - private var lastStallTime = 0L - private var totalStallDuration = Duration.ZERO - private var lastIsPlayingTime = if (player.isPlaying) System.currentTimeMillis() else 0L - private var totalPlaytimeDuration = Duration.ZERO - - private var bandwidth = 0L - private var bufferDuration = Duration.ZERO - - private var audioFormat: Format? = player.audioFormat - private var videoFormat: Format? = player.videoFormat - - override fun onStallChanged(eventTime: AnalyticsListener.EventTime, isStall: Boolean) { - if (isStall) { - lastStallTime = System.currentTimeMillis() - stallCount++ - } else { - totalStallDuration += computeStallDuration() - lastStallTime = 0 - } - } - - override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { - if (isPlaying) { - lastIsPlayingTime = System.currentTimeMillis() - } else { - totalPlaytimeDuration += computePlaybackDuration() - lastIsPlayingTime = 0 - } - } - - override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { - reset() - } - } - - override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { - bandwidth = bitrateEstimate - } - - override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - videoFormat = format - } - - override fun onVideoDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - videoFormat = null - } - - override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - audioFormat = format - } - - override fun onAudioDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - audioFormat = null - } - - override fun onEvents(player: Player, events: AnalyticsListener.Events) { - bufferDuration = player.totalBufferedDuration.milliseconds - } - - private fun computePlaybackDuration(): Duration { - return if (lastIsPlayingTime > 0) (System.currentTimeMillis() - lastIsPlayingTime).milliseconds - else Duration.ZERO - } - - private fun computeStallDuration(): Duration { - return if (lastStallTime > 0) (System.currentTimeMillis() - lastStallTime).milliseconds - else Duration.ZERO - } - - private fun computeBitrate(): Int { - val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE - val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE - var bitrate = 0 - if (videoBitrate > 0) bitrate += videoBitrate - if (audioBitrate > 0) bitrate += audioBitrate - return bitrate - } - - private fun reset() { - stallCount = 0 - totalStallDuration = Duration.ZERO - lastStallTime = 0 - - lastIsPlayingTime = 0 - totalPlaytimeDuration = Duration.ZERO - - bufferDuration = Duration.ZERO - - audioFormat = player.audioFormat - videoFormat = player.videoFormat - if (player.isPlaying) { - lastIsPlayingTime = System.currentTimeMillis() - } - } - - /** - * Get current metrics - * - * @return metrics to the current time - */ - fun getCurrentMetrics(): PlaybackStats { - return PlaybackStats( - bandwidth = bandwidth, - bitrate = computeBitrate(), - bufferDuration = bufferDuration, - playbackDuration = totalPlaytimeDuration + computePlaybackDuration(), - stallCount = stallCount, - stallDuration = totalStallDuration + computeStallDuration() - ) - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index cdf56ec30..3328206b7 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -91,23 +91,14 @@ class PlaybackSessionManager { } /** - * Register player + * Set the player * * @param player */ - fun registerPlayer(player: ExoPlayer) { + fun setPlayer(player: ExoPlayer) { player.addAnalyticsListener(analyticsListener) } - /** - * Unregister player - * - * @param player - */ - fun unregisterPlayer(player: ExoPlayer) { - player.removeAnalyticsListener(analyticsListener) - } - /** * Add listener * @@ -148,8 +139,7 @@ class PlaybackSessionManager { /** * Get session from event time * - * @param eventTime - * @return + * @param eventTime The [AnalyticsListener.EventTime]. */ fun getSessionFromEventTime(eventTime: AnalyticsListener.EventTime): Session? { if (eventTime.timeline.isEmpty) { @@ -157,9 +147,16 @@ class PlaybackSessionManager { } eventTime.timeline.getWindow(eventTime.windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + return getSessionFromPeriodUid(periodUid) + } + /** + * Get session from a period uid + * + * @param periodUid The period uid. + */ + fun getSessionFromPeriodUid(periodUid: Any): Session? { return sessions[periodUid] } @@ -281,17 +278,16 @@ class PlaybackSessionManager { override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { DebugLogger.debug(TAG, "onPlayerReleased") finishAllSessions() + listeners.clear() } private fun getOrCreateSession(eventTime: AnalyticsListener.EventTime): Session? { if (eventTime.timeline.isEmpty) { return null } - eventTime.timeline.getWindow(eventTime.windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) - var session = sessions[periodUid] + var session = getSessionFromPeriodUid(periodUid) if (session == null) { val newSession = Session(periodUid, window.mediaItem) sessions[periodUid] = newSession diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt deleted file mode 100644 index 432a0e9bb..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.analytics - -import kotlin.time.Duration - -/** - * Represents a generic event, which contains metrics about the current media stream. - * - * @property bandwidth The device-measured network bandwidth, in bytes per second. - * @property bitrate The bitrate of the current stream, in bytes per second. - * @property bufferDuration The forward duration of the buffer, in milliseconds. - * @property playbackDuration The duration of the playback, in milliseconds. - * @property stallCount The number of stalls that have occurred, not as a result of a seek. - * @property stallDuration The total duration of the stalls, in milliseconds. - */ -data class PlaybackStats( - val bandwidth: Long, - val bitrate: Int, - val bufferDuration: Duration, - val playbackDuration: Duration, - val stallCount: Int, - val stallDuration: Duration, -) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt deleted file mode 100644 index e83c0af01..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.analytics - -import android.util.Log -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import java.io.IOException -import kotlin.time.Duration.Companion.milliseconds - -/** - * Stall tracker - * # Definition of a Stall - * A Stall occurs when the player is buffering during playback without user interaction. - */ -class StallTracker : AnalyticsListener { - private var stallCount = 0 - private var lastStallTime = 0L - private var stallDuration = 0L - private var lastIsPlaying = 0L - private var totalPlaytimeDuration = 0L - - private enum class State { - IDLE, - READY, - STALLED, - SEEKING, - } - - private var state: State = State.IDLE - set(value) { - if (value == field) return - if (value == State.STALLED) { - lastStallTime = System.currentTimeMillis() - stallCount++ - } - if (field == State.STALLED) { - stallDuration += System.currentTimeMillis() - lastStallTime - } - field = value - } - - private fun reset() { - state = State.IDLE - - Log.d(TAG, "Metrics: #Stalls = $stallCount duration = ${stallDuration.milliseconds} totalPlayTime = ${totalPlaytimeDuration.milliseconds}") - stallCount = 0 - lastStallTime = 0L - stallDuration = 0 - totalPlaytimeDuration = 0 - } - - override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { - reset() - } - } - - override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { - reset() - } - - override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { - reset() - } - - override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { - if (isPlaying) { - lastIsPlaying = System.currentTimeMillis() - } else { - totalPlaytimeDuration += System.currentTimeMillis() - lastIsPlaying - } - } - - @Suppress("ComplexCondition") - override fun onPositionDiscontinuity( - eventTime: AnalyticsListener.EventTime, - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - val isNotStalled = state != State.STALLED - val isSameMediaItem = oldPosition.mediaItemIndex == newPosition.mediaItemIndex - val isSeekDiscontinuity = reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT - - if (isNotStalled && isSameMediaItem && isSeekDiscontinuity) { - state = State.SEEKING - } - } - - override fun onLoadError( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - error: IOException, - wasCanceled: Boolean - ) { - if (state == State.READY || state == State.SEEKING) { - state = State.STALLED - } - } - - override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, playbackState: Int) { - when (playbackState) { - Player.STATE_READY -> { - state = State.READY - } - - Player.STATE_BUFFERING -> { - if (state == State.READY) { - state = State.STALLED - } - } - - else -> { - reset() - } - } - } - - companion object { - private const val TAG = "Stalls" - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt similarity index 82% rename from pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt index 47d4f741d..57c2faf04 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt @@ -2,9 +2,10 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business.tracker +package ch.srgssr.pillarbox.player.analytics import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds /** @@ -15,13 +16,21 @@ import kotlin.time.Duration.Companion.milliseconds class TotalPlaytimeCounter internal constructor( private val timeProvider: () -> Long, ) { - private var totalPlayTime: Duration = Duration.ZERO + private var totalPlayTime: Duration = ZERO private var lastPlayTime = 0L constructor() : this( timeProvider = { System.currentTimeMillis() }, ) + /** + * Reset total playtime to zero + */ + fun reset() { + totalPlayTime = ZERO + lastPlayTime = 0L + } + /** * Play * Calling twice play after sometime will compute totalPlaytime diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt new file mode 100644 index 000000000..69e5d09ab --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.extension + +import androidx.media3.common.Timeline.Window +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime + +internal fun EventTime.getUidOfPeriod(window: Window): Any { + timeline.getWindow(windowIndex, window) + return timeline.getUidOfPeriod(window.firstPeriodIndex) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt new file mode 100644 index 000000000..f3f8b31aa --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.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.analytics.metrics + +import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.utils.StringUtil +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class LoadingTimes( + private val onLoadingReady: () -> Unit, + private val timeProvider: () -> Long = { System.currentTimeMillis() }, + var source: Duration? = null, + var manifest: Duration? = null, + var asset: Duration? = null, + var drm: Duration? = null, +) { + private var bufferingStartTime: Long = 0L + var timeToReady: Duration? = null + private set + + var state: @Player.State Int = Player.STATE_IDLE + set(value) { + if (field == value) return + if (field == Player.STATE_READY && value == Player.STATE_BUFFERING) return + + if ((field == Player.STATE_IDLE || field == Player.STATE_ENDED) && value == Player.STATE_BUFFERING) { + bufferingStartTime = timeProvider() + } + if (field == Player.STATE_BUFFERING && value == Player.STATE_READY) { + timeToReady = (timeProvider() - bufferingStartTime).milliseconds + } + field = value + if (field == Player.STATE_READY) { + onLoadingReady() + } + } + + override fun toString(): String { + return "LoadingTimes(bufferingStartTime=$bufferingStartTime, timeToReady=$timeToReady, state=${StringUtil.playerStateString(state)})" + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt new file mode 100644 index 000000000..954be5a35 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import androidx.annotation.VisibleForTesting +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Player +import androidx.media3.common.Timeline.Window +import androidx.media3.exoplayer.DecoderCounters +import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener +import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter +import ch.srgssr.pillarbox.player.analytics.extension.getUidOfPeriod +import ch.srgssr.pillarbox.player.source.PillarboxMediaSource +import ch.srgssr.pillarbox.player.utils.DebugLogger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Playback stats metrics + * Compute playback stats metrics likes stalls, playtime, bitrate, etc... + */ +class MetricsCollector @VisibleForTesting private constructor( + private val timeProvider: () -> Long, +) : PillarboxAnalyticsListener, PlaybackSessionManager.Listener { + /** + * Listener + */ + interface Listener { + /** + * On metric session finished + * + * @param metrics The [PlaybackMetrics] that belong to te finished session. + */ + fun onMetricSessionFinished(metrics: PlaybackMetrics) = Unit + + /** + * On metric session ready + * + * @param metrics + */ + fun onMetricSessionReady(metrics: PlaybackMetrics) = Unit + } + + private val totalPlaytimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalStallTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalBufferingTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) + private var stallCount = 0 + private var bandwidth = 0L + private var bufferDuration = Duration.ZERO + private var audioFormat: Format? = null + private var videoFormat: Format? = null + private val window = Window() + private val loadingTimes = mutableMapOf() + private var currentSession: PlaybackSessionManager.Session? = null + private val listeners = mutableSetOf() + private lateinit var player: PillarboxExoPlayer + + constructor() : this({ System.currentTimeMillis() }) + + /** + * Set player at [PillarboxExoPlayer] creation. + */ + fun setPlayer(player: PillarboxExoPlayer) { + player.sessionManager.addListener(this) + player.addAnalyticsListener(this) + this.player = player + } + + /** + * Add listener + * + * @param listener + */ + fun addListener(listener: Listener) { + listeners.add(listener) + } + + /** + * Remove listener + * + * @param listener + */ + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + private fun notifyMetricsFinished(metrics: PlaybackMetrics) { + listeners.toList().forEach { + it.onMetricSessionFinished(metrics) + } + } + + private fun notifyMetricsReady(metrics: PlaybackMetrics) { + if (currentSession?.sessionId != metrics.sessionId) return + DebugLogger.debug(TAG, "notifyMetricsReady $metrics") + listeners.toList().forEach { + it.onMetricSessionReady(metrics) + } + } + + override fun onSessionCreated(session: PlaybackSessionManager.Session) { + getOrCreateLoadingTimes(session.periodUid) + } + + override fun onSessionFinished(session: PlaybackSessionManager.Session) { + getMetricsForSession(session)?.let { + DebugLogger.debug(TAG, "onSessionFinished: $it") + notifyMetricsFinished(it) + } + loadingTimes.remove(session.periodUid) + reset() + } + + override fun onCurrentSession(session: PlaybackSessionManager.Session) { + currentSession = session + val loadingTimes = loadingTimes[session.periodUid] + if (loadingTimes?.state == Player.STATE_READY) { + getCurrentMetrics()?.let(this::notifyMetricsReady) + } + } + + private fun getOrCreateLoadingTimes(periodUid: Any): LoadingTimes { + return loadingTimes.getOrPut(periodUid) { + LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + player.sessionManager.getSessionFromPeriodUid(periodUid)?.let { + getMetricsForSession(it)?.let(this::notifyMetricsReady) + } + }) + } + } + + override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { + if (isStall) { + totalStallTimeCounter.play() + stallCount++ + } else { + totalStallTimeCounter.pause() + } + } + + override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { + if (isPlaying) { + totalPlaytimeCounter.play() + } else { + totalPlaytimeCounter.pause() + } + } + + override fun onBandwidthEstimate(eventTime: EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { + bandwidth = bitrateEstimate + } + + override fun onVideoInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + videoFormat = format + } + + override fun onVideoDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { + videoFormat = null + } + + override fun onAudioInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + audioFormat = format + } + + override fun onAudioDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { + audioFormat = null + } + + private fun updateStartupTimeWithState(eventTime: EventTime, state: Int) { + if (eventTime.timeline.isEmpty) return + val periodUid = eventTime.getUidOfPeriod(window) + val startupTimes = getOrCreateLoadingTimes(periodUid) + startupTimes.state = state + } + + override fun onPlaybackStateChanged(eventTime: EventTime, state: Int) { + updateStartupTimeWithState(eventTime, state) + when (state) { + Player.STATE_BUFFERING -> { + totalBufferingTimeCounter.play() + } + + Player.STATE_READY -> { + totalBufferingTimeCounter.pause() + } + } + } + + override fun onRenderedFirstFrame(eventTime: EventTime, output: Any, renderTimeMs: Long) { + updateStartupTimeWithState(eventTime, player.playbackState) + } + + override fun onAudioPositionAdvancing(eventTime: EventTime, playoutStartSystemTimeMs: Long) { + updateStartupTimeWithState(eventTime, player.playbackState) + } + + override fun onEvents(player: Player, events: AnalyticsListener.Events) { + bufferDuration = player.totalBufferedDuration.milliseconds + } + + override fun onLoadCompleted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + if (eventTime.timeline.isEmpty) return + val periodUid = eventTime.getUidOfPeriod(window) + val loadingTimes = getOrCreateLoadingTimes(periodUid) + val loadDuration = loadEventInfo.loadDurationMs.milliseconds + when (mediaLoadData.dataType) { + C.DATA_TYPE_DRM -> { + if (loadingTimes.drm == null) { + loadingTimes.drm = loadDuration + } + } + + C.DATA_TYPE_MANIFEST -> { + if (loadingTimes.manifest == null) { + loadingTimes.manifest = loadDuration + } + } + + C.DATA_TYPE_MEDIA -> { + if (loadingTimes.source == null) { + loadingTimes.source = loadDuration + } + } + + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { + if (loadingTimes.asset == null) { + loadingTimes.asset = loadDuration + } + } + + else -> { + } + } + } + + override fun onPlayerReleased(eventTime: EventTime) { + listeners.clear() + } + + private fun computeBitrate(): Int { + val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE + val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE + var bitrate = 0 + if (videoBitrate > 0) bitrate += videoBitrate + if (audioBitrate > 0) bitrate += audioBitrate + return bitrate + } + + private fun reset() { + stallCount = 0 + totalStallTimeCounter.reset() + totalPlaytimeCounter.reset() + totalBufferingTimeCounter.reset() + + bufferDuration = Duration.ZERO + + audioFormat = player.audioFormat + videoFormat = player.videoFormat + if (player.isPlaying) { + totalPlaytimeCounter.play() + } + } + + /** + * Get current metrics + * + * @return metrics to the current time + */ + fun getCurrentMetrics(): PlaybackMetrics? { + return currentSession?.let { + getMetricsForSession(it) + } + } + + /** + * Get metrics for session + * + * @param session + * @return + */ + fun getMetricsForSession(session: PlaybackSessionManager.Session): PlaybackMetrics? { + val loadingTimes = getOrCreateLoadingTimes(session.periodUid) + return PlaybackMetrics( + sessionId = session.sessionId, + bandwidth = bandwidth, + bitrate = computeBitrate(), + bufferDuration = bufferDuration, + playbackDuration = totalPlaytimeCounter.getTotalPlayTime(), + stallCount = stallCount, + stallDuration = totalStallTimeCounter.getTotalPlayTime(), + loadDuration = loadingTimes.toLoadDuration() + ) + } + + private companion object { + const val TAG = "MetricsCollector" + + private fun LoadingTimes.toLoadDuration(): PlaybackMetrics.LoadDuration { + return PlaybackMetrics.LoadDuration( + source = source, + manifest = manifest, + drm = drm, + asset = asset, + timeToReady = timeToReady, + ) + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt new file mode 100644 index 000000000..558ca4a1c --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import kotlin.time.Duration + +/** + * Represents a generic event, which contains metrics about the current media stream. + * + * @property sessionId The session ID. + * @property bandwidth The device-measured network bandwidth, in bytes per second. + * @property bitrate The bitrate of the current stream, in bytes per second. + * @property bufferDuration The forward duration of the buffer. + * @property playbackDuration The duration of the playback. + * @property stallCount The number of stalls that have occurred, not as a result of a seek. + * @property stallDuration The total duration of the stalls. + * @property loadDuration The load duration that could be computed. + */ +data class PlaybackMetrics( + val sessionId: String, + val bandwidth: Long = 0, + val bitrate: Int = 0, + val bufferDuration: Duration = Duration.ZERO, + val playbackDuration: Duration = Duration.ZERO, + val stallCount: Int = 0, + val stallDuration: Duration = Duration.ZERO, + val loadDuration: LoadDuration = LoadDuration() +) { + + /** + * Load duration + * Represents the timings until the current media started to play. + * @property source The time spent to load the media source. + * @property manifest The time spent to load the main manifest if applicable. + * @property asset The time spent to load the asset. + * @property drm The time spent to load the DRM. + * @property timeToReady The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until + * it started to play. + */ + data class LoadDuration( + val source: Duration? = null, + val manifest: Duration? = null, + val asset: Duration? = null, + val drm: Duration? = null, + val timeToReady: Duration? = null + ) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt index a29a8e6e9..24c48eac0 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt @@ -7,7 +7,6 @@ package ch.srgssr.pillarbox.player.qos import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK -import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT import androidx.media3.common.Player.DiscontinuityReason import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime @@ -15,7 +14,6 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Listener import ch.srgssr.pillarbox.player.utils.DebugLogger -import ch.srgssr.pillarbox.player.utils.StringUtil /** * Pillarbox provided implementation of [QoSEventsDispatcher]. @@ -59,9 +57,7 @@ class PillarboxEventsDispatcher( val oldItemIndex = oldPosition.mediaItemIndex val newItemIndex = newPosition.mediaItemIndex - DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") - - if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK || reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { + if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK) { val session = sessionManager.getCurrentSession() ?: return notifyListeners { onSeek(session) } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 65702fcda..6889a92eb 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -5,15 +5,16 @@ package ch.srgssr.pillarbox.player.qos import android.content.Context +import android.util.Log import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.PlaybackStats +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat import kotlin.coroutines.CoroutineContext @@ -23,12 +24,11 @@ internal class QoSCoordinator( private val context: Context, private val player: ExoPlayer, private val eventsDispatcher: QoSEventsDispatcher, - private val startupTimesTracker: StartupTimesTracker, private val metricsCollector: MetricsCollector, private val messageHandler: QoSMessageHandler, private val sessionManager: PlaybackSessionManager, coroutineContext: CoroutineContext, -) : PillarboxAnalyticsListener { +) : PillarboxAnalyticsListener, MetricsCollector.Listener { private val heartbeat = Heartbeat( period = HEARTBEAT_PERIOD, coroutineContext = coroutineContext, @@ -47,15 +47,34 @@ internal class QoSCoordinator( eventsDispatcher.registerPlayer(player) eventsDispatcher.addListener(eventsDispatcherListener) - eventsDispatcher.addListener(startupTimesTracker) - sessionManager.registerPlayer(player) sessionManager.addListener(eventsDispatcherListener) - sessionManager.addListener(startupTimesTracker) - - player.addAnalyticsListener(startupTimesTracker) - player.addAnalyticsListener(metricsCollector) player.addAnalyticsListener(this) + metricsCollector.addListener(this) + } + + override fun onMetricSessionReady(metrics: PlaybackMetrics) { + DebugLogger.info(TAG, "onMetricSessionReady $metrics") + + heartbeat.start(restart = false) + sessionManager.getSessionById(metrics.sessionId)?.let { + sendStartEvent( + session = it, + timings = QoSSessionTimings( + asset = metrics.loadDuration.asset, + mediaSource = metrics.loadDuration.source, + currentToStart = metrics.loadDuration.timeToReady, + drm = metrics.loadDuration.drm + ) + ) + } + } + + override fun onMetricSessionFinished(metrics: PlaybackMetrics) { + heartbeat.stop() + sessionManager.getSessionById(metrics.sessionId)?.let { + sendEvent("END", it) + } ?: Log.wtf(TAG, "Should have a session!") } override fun onEvents(player: Player, events: AnalyticsListener.Events) { @@ -76,16 +95,16 @@ internal class QoSCoordinator( session: PlaybackSessionManager.Session, data: Any? = null, ) { + val dataToSend = data ?: metricsCollector.getCurrentMetrics()?.toQoSEvent() ?: return val message = QoSMessage( - data = data ?: metricsCollector.getCurrentMetrics().toQoSEvent(), + data = dataToSend, eventName = eventName, sessionId = session.sessionId, ) - messageHandler.sendEvent(message) } - private fun PlaybackStats.toQoSEvent(): QoSEvent { + private fun PlaybackMetrics.toQoSEvent(): QoSEvent { val bitrateBytes = bitrate / BITS val bandwidthBytes = bandwidth / BITS return QoSEvent( @@ -107,17 +126,10 @@ internal class QoSCoordinator( } override fun onSessionFinished(session: PlaybackSessionManager.Session) { - heartbeat.stop() - sendEvent("END", session) currentSession = null } override fun onMediaStart(session: PlaybackSessionManager.Session) { - val startupTimes = startupTimesTracker.consumeStartupTimes(session.sessionId) ?: return - - heartbeat.start(restart = false) - - sendStartEvent(session, startupTimes) } override fun onIsPlaying( @@ -141,7 +153,7 @@ internal class QoSCoordinator( override fun onError(session: PlaybackSessionManager.Session) { if (sessionManager.getSessionById(session.sessionId) == null) { - sendStartEvent(session, QoSSessionTimings.Zero) + sendStartEvent(session, QoSSessionTimings.Empty) } player.playerError?.let { @@ -160,32 +172,24 @@ internal class QoSCoordinator( override fun onPlayerReleased() { eventsDispatcher.unregisterPlayer(player) eventsDispatcher.removeListener(this) - eventsDispatcher.removeListener(startupTimesTracker) - - sessionManager.unregisterPlayer(player) sessionManager.removeListener(this) - sessionManager.removeListener(startupTimesTracker) - - player.removeAnalyticsListener(startupTimesTracker) - player.removeAnalyticsListener(metricsCollector) - player.removeAnalyticsListener(this@QoSCoordinator) } + } - private fun sendStartEvent( - session: PlaybackSessionManager.Session, - timings: QoSSessionTimings, - ) { - sendEvent( - eventName = "START", - session = session, - data = QoSSession( - context = context, - mediaId = session.mediaItem.mediaId, - mediaSource = session.mediaItem.localConfiguration?.uri.toString(), - timings = timings, - ), - ) - } + private fun sendStartEvent( + session: PlaybackSessionManager.Session, + timings: QoSSessionTimings, + ) { + sendEvent( + eventName = "START", + session = session, + data = QoSSession( + context = context, + mediaId = session.mediaItem.mediaId, + mediaSource = session.mediaItem.localConfiguration?.uri.toString(), + timings = timings, + ), + ) } private companion object { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt index c63c7d1ea..1405c01e6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt @@ -43,7 +43,7 @@ data class QoSSession( val playerVersion: String = PLAYER_VERSION, val screenHeight: Int, val screenWidth: Int, - val timings: QoSSessionTimings = QoSSessionTimings.Zero, + val timings: QoSSessionTimings = QoSSessionTimings.Empty, ) { /** * The type of device. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt index 6db8bf8b1..2128a86e8 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt @@ -16,20 +16,20 @@ import kotlin.time.Duration * @property mediaSource The time spent to load the media source. */ data class QoSSessionTimings( - val asset: Duration, - val currentToStart: Duration, - val drm: Duration, - val mediaSource: Duration, + val asset: Duration? = null, + val currentToStart: Duration? = null, + val drm: Duration? = null, + val mediaSource: Duration? = null, ) { companion object { /** - * Default [QoSSessionTimings] where all fields are a duration of zero. + * Default [QoSSessionTimings] where all fields are set to `null`. */ - val Zero = QoSSessionTimings( - asset = Duration.ZERO, - currentToStart = Duration.ZERO, - drm = Duration.ZERO, - mediaSource = Duration.ZERO, + val Empty = QoSSessionTimings( + asset = null, + currentToStart = null, + drm = null, + mediaSource = null, ) } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt deleted file mode 100644 index 3a13480a6..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import androidx.media3.common.C -import androidx.media3.common.Timeline -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource -import kotlin.time.Duration.Companion.milliseconds - -internal class StartupTimesTracker : AnalyticsListener, PlaybackSessionManager.Listener, QoSEventsDispatcher.Listener { - private val loadingSessions = mutableSetOf() - private val periodUidToSessionId = mutableMapOf() - private val currentSessionToMediaStart = mutableMapOf() - private val qosSessionsTimings = mutableMapOf() - private val window = Timeline.Window() - - fun consumeStartupTimes(sessionId: String): QoSSessionTimings? { - if (loadingSessions.remove(sessionId)) { - val sessionTimings = checkNotNull(qosSessionsTimings.remove(sessionId)) - val currentSessionToMediaStart = currentSessionToMediaStart.remove(sessionId) - - return if (currentSessionToMediaStart != null) { - sessionTimings.copy(currentToStart = (System.currentTimeMillis() - currentSessionToMediaStart).milliseconds) - } else { - sessionTimings - } - } - - return null - } - - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - loadingSessions.add(session.sessionId) - periodUidToSessionId[session.periodUid] = session.sessionId - qosSessionsTimings[session.sessionId] = QoSSessionTimings.Zero - } - - override fun onCurrentSession(session: PlaybackSessionManager.Session) { - currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() - } - - override fun onSessionFinished(session: PlaybackSessionManager.Session) { - loadingSessions.remove(session.sessionId) - periodUidToSessionId.remove(session.periodUid) - qosSessionsTimings.remove(session.sessionId) - } - - override fun onLoadCompleted( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - ) { - val sessionId = getSessionId(eventTime) - if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessionsTimings) { - return - } - - val qosSessionTimings = qosSessionsTimings.getValue(sessionId) - val loadDuration = loadEventInfo.loadDurationMs.milliseconds - - qosSessionsTimings[sessionId] = when (mediaLoadData.dataType) { - C.DATA_TYPE_DRM -> qosSessionTimings.copy(drm = qosSessionTimings.drm + loadDuration) - C.DATA_TYPE_MANIFEST, C.DATA_TYPE_MEDIA -> qosSessionTimings.copy(mediaSource = qosSessionTimings.mediaSource + loadDuration) - PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> qosSessionTimings.copy(asset = qosSessionTimings.asset + loadDuration) - else -> qosSessionTimings - } - } - - private fun getSessionId(eventTime: AnalyticsListener.EventTime): String? { - val timeline = eventTime.timeline - if (timeline.isEmpty) { - return null - } - - val firstPeriodIndex = timeline.getWindow(eventTime.windowIndex, window).firstPeriodIndex - val periodUid = timeline.getUidOfPeriod(firstPeriodIndex) - return periodUidToSessionId[periodUid] - } -} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt similarity index 92% rename from pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt index 5890bb1bc..7f338c647 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt @@ -2,8 +2,9 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business.tracker +package ch.srgssr.pillarbox.player +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt new file mode 100644 index 000000000..66e2d9f8e --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.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.analytics + +import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.analytics.metrics.LoadingTimes +import io.mockk.clearAllMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class LoadingTimesTest { + @AfterTest + fun tearDown() { + clearAllMocks() + } + + @Test + fun `buffering to ready call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + advanceTimeBy(10.seconds) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } + + @Test + fun `not ready don't call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + advanceTimeBy(10.seconds) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_ENDED + + verify(exactly = 0) { + callback() + } + confirmVerified(callback) + assertNull(loadingTimes.timeToReady) + } + + @Test + fun `initialization to READY call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNull(timeToReady) + } + + @Test + fun `call twice ready call once onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } + + @Test + fun `twice buffering and ready keep only the first timeToReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt new file mode 100644 index 000000000..20e2c63d6 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import android.content.Context +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +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.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.SeekIncrement +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import io.mockk.clearAllMocks +import io.mockk.clearMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +@RunWith(AndroidJUnit4::class) +class MetricsCollectorTest { + + private lateinit var player: PillarboxExoPlayer + private lateinit var metricsCollector: MetricsCollector + private lateinit var fakeClock: FakeClock + private lateinit var metricsListener: MetricsCollector.Listener + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + metricsListener = mockk(relaxed = true) + fakeClock = FakeClock(true) + metricsCollector = MetricsCollector() + player = PillarboxExoPlayer( + context = context, + seekIncrement = SeekIncrement(), + loadControl = DefaultLoadControl(), + clock = fakeClock, + coroutineContext = EmptyCoroutineContext, + mediaSourceFactory = PillarboxMediaSourceFactory(context), + metricsCollector = metricsCollector + ) + metricsCollector.addListener(metricsListener) + player.prepare() + + clearMocks(metricsListener) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + player.release() + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `single item playback`() { + player.setMediaItem(VOD1) + player.play() + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // Session is finished when starting another media or when there is no more current item + player.clearMediaItems() + player.stop() + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val slotReady = slot() + val slotFinished = slot() + verify { + metricsListener.onMetricSessionReady(capture(slotReady)) + metricsListener.onMetricSessionFinished(capture(slotFinished)) + } + confirmVerified(metricsListener) + + assertTrue(slotReady.isCaptured) + slotReady.captured.also { + Assert.assertNotNull(it.loadDuration.source) + Assert.assertNotNull(it.loadDuration.manifest) + Assert.assertNotNull(it.loadDuration.timeToReady) + Assert.assertNotNull(it.loadDuration.asset) + Assert.assertNull(it.loadDuration.drm) + assertEquals(Duration.ZERO, it.playbackDuration) + } + + assertTrue(slotFinished.isCaptured) + slotFinished.captured.also { + Assert.assertNotNull(it.loadDuration.source) + Assert.assertNotNull(it.loadDuration.manifest) + Assert.assertNotNull(it.loadDuration.timeToReady) + Assert.assertNotNull(it.loadDuration.asset) + Assert.assertNull(it.loadDuration.drm) + assertNotEquals(Duration.ZERO, it.playbackDuration) + } + } + + @Test + fun `playback item transition`() { + player.setMediaItems(listOf(VOD1, VOD2)) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val startedMetrics = mutableListOf() + val finishedMetrics = mutableListOf() + verify { + metricsListener.onMetricSessionReady(capture(startedMetrics)) + metricsListener.onMetricSessionFinished(capture(finishedMetrics)) + } + confirmVerified(metricsListener) + + assertEquals(2, finishedMetrics.size) + assertNotEquals(finishedMetrics[0].sessionId, finishedMetrics[1].sessionId) + + assertEquals(2, startedMetrics.size) + assertNotEquals(startedMetrics[0].sessionId, startedMetrics[1].sessionId) + } + + private companion object { + private val VOD1 = MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8") + private val VOD2 = MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8") + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt index 433e63b0c..4b7ceec81 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -51,7 +51,7 @@ class PlaybackSessionManagerTest { } sessionManager = PlaybackSessionManager().apply { - registerPlayer(player) + setPlayer(player) addListener(sessionManagerListener) } @@ -61,8 +61,6 @@ class PlaybackSessionManagerTest { @AfterTest fun tearDown() { clearAllMocks() - sessionManager.unregisterPlayer(player) - sessionManager.removeListener(sessionManagerListener) player.release() shadowOf(Looper.getMainLooper()).idle() } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt index 26bfab447..da1ce7788 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt @@ -46,7 +46,7 @@ class QoSEventsDispatcherTest { } val sessionManager = PlaybackSessionManager().apply { - registerPlayer(player) + setPlayer(player) } PillarboxEventsDispatcher(sessionManager).apply { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt index 28fe635e6..966ec0db6 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt @@ -29,7 +29,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -48,7 +48,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -67,7 +67,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -86,7 +86,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -105,7 +105,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -124,7 +124,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -143,7 +143,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -162,7 +162,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } private fun createQoSSession(): QoSSession { @@ -172,7 +172,7 @@ class QoSSessionTest { context = context, mediaId = "urn:rts:video:12345", mediaSource = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY", - timings = QoSSessionTimings.Zero, + timings = QoSSessionTimings.Empty, ) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt index 4925dbe4c..37e499a39 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt @@ -5,17 +5,16 @@ package ch.srgssr.pillarbox.player.qos import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration +import kotlin.test.assertNull class QoSSessionTimingsTest { @Test fun `zero timings`() { - val timings = QoSSessionTimings.Zero + val timings = QoSSessionTimings.Empty - assertEquals(Duration.ZERO, timings.asset) - assertEquals(Duration.ZERO, timings.currentToStart) - assertEquals(Duration.ZERO, timings.drm) - assertEquals(Duration.ZERO, timings.mediaSource) + assertNull(timings.asset) + assertNull(timings.currentToStart) + assertNull(timings.drm) + assertNull(timings.mediaSource) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt deleted file mode 100644 index a12eb39f5..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import android.content.Context -import android.os.Looper -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.test.utils.FakeClock -import androidx.media3.test.utils.robolectric.TestPlayerRunHelper -import androidx.test.core.app.ApplicationProvider -import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.player.analytics.MetricsCollector -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import org.junit.runner.RunWith -import org.robolectric.ParameterizedRobolectricTestRunner -import org.robolectric.ParameterizedRobolectricTestRunner.Parameters -import org.robolectric.Shadows.shadowOf -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.time.Duration.Companion.seconds - -@RunWith(ParameterizedRobolectricTestRunner::class) -class StartupTimesTrackerTest( - private val mediaUrls: List, -) { - private lateinit var player: Player - private lateinit var startupTimesTracker: StartupTimesTracker - private lateinit var sessionId: String - - @BeforeTest - fun setUp() { - startupTimesTracker = StartupTimesTracker() - player = createPlayer(mediaUrls) - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - - mediaUrls.forEachIndexed { index, _ -> - player.seekTo(5.seconds.inWholeMilliseconds) - player.seekToNextMediaItem() - } - } - - @Test - fun `consume startup times`() { - val startupTimes = startupTimesTracker.consumeStartupTimes(sessionId) - - assertNotNull(startupTimes) - assertNull(startupTimesTracker.consumeStartupTimes(sessionId)) - } - - @AfterTest - fun tearDown() { - player.release() - - shadowOf(Looper.getMainLooper()).idle() - } - - private fun createPlayer(mediaUrls: List): Player { - val context = ApplicationProvider.getApplicationContext() - val coroutineContext = EmptyCoroutineContext - - return PillarboxExoPlayer( - context = context, - clock = FakeClock(true), - coroutineContext = coroutineContext, - ).apply { - val mediaItems = mediaUrls.map(MediaItem::fromUri) - val sessionManager = PlaybackSessionManager() - sessionManager.registerPlayer(this) - sessionManager.addListener(object : PlaybackSessionManager.Listener { - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - sessionId = session.sessionId - } - }) - - QoSCoordinator( - context = context, - player = this, - eventsDispatcher = PillarboxEventsDispatcher(sessionManager), - startupTimesTracker = startupTimesTracker, - metricsCollector = MetricsCollector(this), - messageHandler = DummyQoSHandler, - sessionManager = sessionManager, - coroutineContext = coroutineContext, - ) - - addMediaItems(mediaItems) - addAnalyticsListener(startupTimesTracker) - prepare() - play() - } - } - - companion object { - @JvmStatic - @Parameters(name = "{index}: {0}") - fun parameters(): Iterable { - return listOf( - // AOD only - listOf("https://rts-aod-dd.akamaized.net/ww/14965091/0ba47e81-57d5-3bc0-b3e5-18c93cc84da3.mp3"), - // VOD only - listOf("https://rts-vod-amd.akamaized.net/ww/14981648/85b72399-a98c-3455-bdad-70c82cdf0a30/master.m3u8"), - // Live DVR audio only - listOf("https://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8"), - // Live DVR video only - listOf("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8"), - // Playlist with mixed content - listOf( - "https://rts-aod-dd.akamaized.net/ww/14967482/effca0a1-d59d-3b64-b7eb-cc58a4ad75d6.mp3", - "https://rts-vod-amd.akamaized.net/ww/14983127/c9a205b7-0b47-35ac-989a-9306883470bf/master.m3u8", - ), - ) - } - } -} From f957313373f1f8a32c86aad697f079e0dfcc2775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 23 Jul 2024 15:03:06 +0200 Subject: [PATCH 10/17] Improve metrics collector (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller --- .../analytics/metrics/MetricsCollector.kt | 254 ++++++++---------- .../analytics/metrics/PlaybackMetrics.kt | 42 ++- .../analytics/metrics/SessionMetrics.kt | 189 +++++++++++++ .../pillarbox/player/qos/QoSCoordinator.kt | 23 +- .../player/analytics/SessionMetricsTest.kt | 154 +++++++++++ 5 files changed, 501 insertions(+), 161 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt index 954be5a35..ce749b994 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt @@ -5,25 +5,21 @@ package ch.srgssr.pillarbox.player.analytics.metrics import androidx.annotation.VisibleForTesting -import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player import androidx.media3.common.Timeline.Window +import androidx.media3.common.util.Size import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import androidx.media3.exoplayer.drm.DrmSession import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData import ch.srgssr.pillarbox.player.PillarboxExoPlayer import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.analytics.extension.getUidOfPeriod -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource import ch.srgssr.pillarbox.player.utils.DebugLogger -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds /** * Playback stats metrics @@ -51,19 +47,12 @@ class MetricsCollector @VisibleForTesting private constructor( fun onMetricSessionReady(metrics: PlaybackMetrics) = Unit } - private val totalPlaytimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private val totalStallTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private val totalBufferingTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private var stallCount = 0 - private var bandwidth = 0L - private var bufferDuration = Duration.ZERO - private var audioFormat: Format? = null - private var videoFormat: Format? = null private val window = Window() - private val loadingTimes = mutableMapOf() private var currentSession: PlaybackSessionManager.Session? = null private val listeners = mutableSetOf() private lateinit var player: PillarboxExoPlayer + private val metricsSessions = mutableMapOf() + private var surfaceSize: Size = Size.UNKNOWN constructor() : this({ System.currentTimeMillis() }) @@ -94,181 +83,151 @@ class MetricsCollector @VisibleForTesting private constructor( listeners.remove(listener) } - private fun notifyMetricsFinished(metrics: PlaybackMetrics) { + private fun notifyMetricsFinished(playbackMetrics: PlaybackMetrics) { + DebugLogger.debug(TAG, "notifyMetricsFinished $playbackMetrics") listeners.toList().forEach { - it.onMetricSessionFinished(metrics) + it.onMetricSessionFinished(playbackMetrics) } } - private fun notifyMetricsReady(metrics: PlaybackMetrics) { - if (currentSession?.sessionId != metrics.sessionId) return - DebugLogger.debug(TAG, "notifyMetricsReady $metrics") + private fun notifyMetricsReady(playbackMetrics: PlaybackMetrics) { + if (currentSession?.sessionId != playbackMetrics.sessionId) return + DebugLogger.debug(TAG, "notifyMetricsReady $playbackMetrics") listeners.toList().forEach { - it.onMetricSessionReady(metrics) + it.onMetricSessionReady(metrics = playbackMetrics) } } override fun onSessionCreated(session: PlaybackSessionManager.Session) { - getOrCreateLoadingTimes(session.periodUid) + getOrCreateSessionMetrics(session.periodUid) } override fun onSessionFinished(session: PlaybackSessionManager.Session) { - getMetricsForSession(session)?.let { - DebugLogger.debug(TAG, "onSessionFinished: $it") - notifyMetricsFinished(it) + metricsSessions.remove(session.periodUid)?.let { + notifyMetricsFinished(createPlaybackMetrics(session = session, metrics = it)) + } + if (currentSession == session) { + currentSession = null } - loadingTimes.remove(session.periodUid) - reset() } override fun onCurrentSession(session: PlaybackSessionManager.Session) { currentSession = session - val loadingTimes = loadingTimes[session.periodUid] - if (loadingTimes?.state == Player.STATE_READY) { - getCurrentMetrics()?.let(this::notifyMetricsReady) + getOrCreateSessionMetrics(session.periodUid).apply { + setIsPlaying(player.isPlaying) + setPlaybackState(player.playbackState) } } - private fun getOrCreateLoadingTimes(periodUid: Any): LoadingTimes { - return loadingTimes.getOrPut(periodUid) { - LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + /** + * Get session metrics + * + * @param eventTime + * @return `null` if there is no item in the timeline + */ + private fun getSessionMetrics(eventTime: EventTime): SessionMetrics? { + if (eventTime.timeline.isEmpty) return null + return getOrCreateSessionMetrics(eventTime.getUidOfPeriod(window)) + } + + private fun getOrCreateSessionMetrics(periodUid: Any): SessionMetrics { + return metricsSessions.getOrPut(periodUid) { + SessionMetrics(timeProvider) { sessionMetrics -> player.sessionManager.getSessionFromPeriodUid(periodUid)?.let { - getMetricsForSession(it)?.let(this::notifyMetricsReady) + notifyMetricsReady(createPlaybackMetrics(session = it, metrics = sessionMetrics)) } - }) + } } } override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { - if (isStall) { - totalStallTimeCounter.play() - stallCount++ - } else { - totalStallTimeCounter.pause() - } + getSessionMetrics(eventTime)?.setIsStall(isStall) } override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { - if (isPlaying) { - totalPlaytimeCounter.play() - } else { - totalPlaytimeCounter.pause() - } + getSessionMetrics(eventTime)?.setIsPlaying(isPlaying) } override fun onBandwidthEstimate(eventTime: EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { - bandwidth = bitrateEstimate + getSessionMetrics(eventTime)?.setBandwidthEstimate(totalLoadTimeMs, totalBytesLoaded, bitrateEstimate) } override fun onVideoInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - videoFormat = format + getSessionMetrics(eventTime)?.videoFormat = format } + /** + * On video disabled is called when releasing the player + * + * @param eventTime + * @param decoderCounters + */ override fun onVideoDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { - videoFormat = null + if (player.playbackState == Player.STATE_IDLE || eventTime.timeline.isEmpty) return + getSessionMetrics(eventTime)?.videoFormat = null } override fun onAudioInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - audioFormat = format + getSessionMetrics(eventTime)?.audioFormat = format } override fun onAudioDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { - audioFormat = null - } - - private fun updateStartupTimeWithState(eventTime: EventTime, state: Int) { - if (eventTime.timeline.isEmpty) return - val periodUid = eventTime.getUidOfPeriod(window) - val startupTimes = getOrCreateLoadingTimes(periodUid) - startupTimes.state = state + if (player.playbackState == Player.STATE_IDLE || eventTime.timeline.isEmpty) return + getSessionMetrics(eventTime)?.audioFormat = null } override fun onPlaybackStateChanged(eventTime: EventTime, state: Int) { - updateStartupTimeWithState(eventTime, state) - when (state) { - Player.STATE_BUFFERING -> { - totalBufferingTimeCounter.play() - } - - Player.STATE_READY -> { - totalBufferingTimeCounter.pause() - } - } + getSessionMetrics(eventTime)?.setPlaybackState(state) } override fun onRenderedFirstFrame(eventTime: EventTime, output: Any, renderTimeMs: Long) { - updateStartupTimeWithState(eventTime, player.playbackState) + getSessionMetrics(eventTime)?.setRenderFirstFrameOrAudioPositionAdvancing() } override fun onAudioPositionAdvancing(eventTime: EventTime, playoutStartSystemTimeMs: Long) { - updateStartupTimeWithState(eventTime, player.playbackState) - } - - override fun onEvents(player: Player, events: AnalyticsListener.Events) { - bufferDuration = player.totalBufferedDuration.milliseconds + getSessionMetrics(eventTime)?.setRenderFirstFrameOrAudioPositionAdvancing() } override fun onLoadCompleted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { - if (eventTime.timeline.isEmpty) return - val periodUid = eventTime.getUidOfPeriod(window) - val loadingTimes = getOrCreateLoadingTimes(periodUid) - val loadDuration = loadEventInfo.loadDurationMs.milliseconds - when (mediaLoadData.dataType) { - C.DATA_TYPE_DRM -> { - if (loadingTimes.drm == null) { - loadingTimes.drm = loadDuration - } - } - - C.DATA_TYPE_MANIFEST -> { - if (loadingTimes.manifest == null) { - loadingTimes.manifest = loadDuration - } - } - - C.DATA_TYPE_MEDIA -> { - if (loadingTimes.source == null) { - loadingTimes.source = loadDuration - } - } + getSessionMetrics(eventTime)?.setLoadCompleted(loadEventInfo, mediaLoadData) + } - PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { - if (loadingTimes.asset == null) { - loadingTimes.asset = loadDuration - } - } + override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + getSessionMetrics(eventTime)?.setLoadStarted(loadEventInfo) + } - else -> { - } + override fun onDrmSessionAcquired(eventTime: EventTime, state: Int) { + DebugLogger.debug(TAG, "onDrmSessionAcquired $state") + if (state == DrmSession.STATE_OPENED) { + getSessionMetrics(eventTime)?.setDrmSessionAcquired() } } - override fun onPlayerReleased(eventTime: EventTime) { - listeners.clear() + override fun onDrmSessionReleased(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmSessionReleased") } - private fun computeBitrate(): Int { - val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE - val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE - var bitrate = 0 - if (videoBitrate > 0) bitrate += videoBitrate - if (audioBitrate > 0) bitrate += audioBitrate - return bitrate + override fun onDrmKeysLoaded(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysLoaded") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() } - private fun reset() { - stallCount = 0 - totalStallTimeCounter.reset() - totalPlaytimeCounter.reset() - totalBufferingTimeCounter.reset() + override fun onDrmKeysRestored(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysRestored") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() + } - bufferDuration = Duration.ZERO + override fun onDrmKeysRemoved(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysRemoved") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() + } - audioFormat = player.audioFormat - videoFormat = player.videoFormat - if (player.isPlaying) { - totalPlaytimeCounter.play() - } + override fun onPlayerReleased(eventTime: EventTime) { + listeners.clear() + } + + override fun onSurfaceSizeChanged(eventTime: EventTime, width: Int, height: Int) { + surfaceSize = Size(width, height) } /** @@ -282,6 +241,31 @@ class MetricsCollector @VisibleForTesting private constructor( } } + private fun createPlaybackMetrics(session: PlaybackSessionManager.Session, metrics: SessionMetrics): PlaybackMetrics { + return PlaybackMetrics( + sessionId = session.sessionId, + bandwidth = metrics.estimateBitrate, + indicatedBitrate = metrics.getTotalBitrate(), + playbackDuration = metrics.totalPlayingDuration, + stallCount = metrics.stallCount, + stallDuration = metrics.totalStallDuration, + bufferingDuration = metrics.totalBufferingDuration, + loadDuration = PlaybackMetrics.LoadDuration( + drm = metrics.totalDrmLoadingDuration, + asset = metrics.asset, + source = metrics.source, + manifest = metrics.manifest, + timeToReady = metrics.timeToReady, + ), + videoFormat = metrics.videoFormat, + audioFormat = metrics.audioFormat, + totalLoadTime = metrics.totalLoadTime, + totalBytesLoaded = metrics.totalBytesLoaded, + url = metrics.url, + surfaceSize = surfaceSize, + ) + } + /** * Get metrics for session * @@ -289,30 +273,12 @@ class MetricsCollector @VisibleForTesting private constructor( * @return */ fun getMetricsForSession(session: PlaybackSessionManager.Session): PlaybackMetrics? { - val loadingTimes = getOrCreateLoadingTimes(session.periodUid) - return PlaybackMetrics( - sessionId = session.sessionId, - bandwidth = bandwidth, - bitrate = computeBitrate(), - bufferDuration = bufferDuration, - playbackDuration = totalPlaytimeCounter.getTotalPlayTime(), - stallCount = stallCount, - stallDuration = totalStallTimeCounter.getTotalPlayTime(), - loadDuration = loadingTimes.toLoadDuration() - ) + return metricsSessions[session.periodUid]?.let { + createPlaybackMetrics(session, it) + } } private companion object { const val TAG = "MetricsCollector" - - private fun LoadingTimes.toLoadDuration(): PlaybackMetrics.LoadDuration { - return PlaybackMetrics.LoadDuration( - source = source, - manifest = manifest, - drm = drm, - asset = asset, - timeToReady = timeToReady, - ) - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt index 558ca4a1c..caa526ac1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt @@ -4,6 +4,11 @@ */ package ch.srgssr.pillarbox.player.analytics.metrics +import android.net.Uri +import androidx.media3.common.Format +import androidx.media3.common.VideoSize +import androidx.media3.common.util.Size +import ch.srgssr.pillarbox.player.extension.videoSize import kotlin.time.Duration /** @@ -11,24 +16,41 @@ import kotlin.time.Duration * * @property sessionId The session ID. * @property bandwidth The device-measured network bandwidth, in bytes per second. - * @property bitrate The bitrate of the current stream, in bytes per second. - * @property bufferDuration The forward duration of the buffer. - * @property playbackDuration The duration of the playback. + * @property indicatedBitrate The bitrate of the video and audio format. + * @property playbackDuration The duration the session spent playing. + * @property bufferingDuration The duration the session spent in buffering. * @property stallCount The number of stalls that have occurred, not as a result of a seek. * @property stallDuration The total duration of the stalls. * @property loadDuration The load duration that could be computed. + * @property totalLoadTime The load time to compute [bandwidth]. + * @property totalBytesLoaded The total bytes loaded to compute [bandwidth]. + * @property url The last url loaded by the player. + * @property videoFormat The current video format selected by the player. + * @property audioFormat The current audio format selected by the player. + * @property surfaceSize The size of the surface connected to the player. [Size.ZERO] if not connected. */ data class PlaybackMetrics( val sessionId: String, - val bandwidth: Long = 0, - val bitrate: Int = 0, - val bufferDuration: Duration = Duration.ZERO, - val playbackDuration: Duration = Duration.ZERO, - val stallCount: Int = 0, - val stallDuration: Duration = Duration.ZERO, - val loadDuration: LoadDuration = LoadDuration() + val bandwidth: Long, + val indicatedBitrate: Long, + val playbackDuration: Duration, + val bufferingDuration: Duration, + val stallCount: Int, + val stallDuration: Duration, + val loadDuration: LoadDuration, + val totalLoadTime: Duration, + val totalBytesLoaded: Long, + val url: Uri?, + val videoFormat: Format?, + val audioFormat: Format?, + val surfaceSize: Size, ) { + /** + * Video size of [videoFormat] if applicable. + */ + val videoSize: VideoSize = videoFormat?.videoSize ?: VideoSize.UNKNOWN + /** * Load duration * Represents the timings until the current media started to play. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt new file mode 100644 index 000000000..ae6c4a2c3 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Player +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter +import ch.srgssr.pillarbox.player.source.PillarboxMediaSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class SessionMetrics internal constructor( + timeProvider: () -> Long = { System.currentTimeMillis() }, + initialPlaybackState: @Player.State Int = Player.STATE_IDLE, + sessionMetricsReady: (SessionMetrics) -> Unit, +) { + private var drmSessionStartedCounter = 0 + private val totalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalStallTimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalBufferingTimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalDrmLoadingCounter = TotalPlaytimeCounter(timeProvider) + private val loadingTimes: LoadingTimes = LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + sessionMetricsReady(this) + }) + private var currentPlaybackState: @Player.State Int = initialPlaybackState + var videoFormat: Format? = null + var audioFormat: Format? = null + var stallCount: Int = 0 + var estimateBitrate: Long = 0 + var totalLoadTime: Duration = Duration.ZERO + var totalBytesLoaded: Long = 0L + val source: Duration? + get() { + return loadingTimes.source + } + val manifest: Duration? + get() { + return loadingTimes.manifest + } + val asset: Duration? + get() { + return loadingTimes.asset + } + val drm: Duration? + get() { + return loadingTimes.drm + } + val timeToReady: Duration? + get() { + return loadingTimes.timeToReady + } + val totalPlayingDuration: Duration + get() { + return totalPlaytimeCounter.getTotalPlayTime() + } + val totalBufferingDuration: Duration + get() { + return totalBufferingTimeCounter.getTotalPlayTime() + } + val totalStallDuration: Duration + get() { + return totalStallTimeCounter.getTotalPlayTime() + } + val totalDrmLoadingDuration: Duration? + get() { + val duration = totalDrmLoadingCounter.getTotalPlayTime() + return if (duration == Duration.ZERO) null else duration + } + var url: Uri? = null + + fun setDrmSessionAcquired() { + if (drmSessionStartedCounter == 0) { + totalDrmLoadingCounter.play() + } + drmSessionStartedCounter++ + } + + fun setDrmKeyLoaded() { + drmSessionStartedCounter-- + if (drmSessionStartedCounter <= 0) { + totalDrmLoadingCounter.pause() + loadingTimes.state = currentPlaybackState + drmSessionStartedCounter = 0 + } + } + + fun getTotalBitrate(): Long { + val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE + val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE + var bitrate = 0L + if (videoBitrate > 0) bitrate += videoBitrate + if (audioBitrate > 0) bitrate += audioBitrate + return bitrate + } + + fun setBandwidthEstimate(totalLoadTimeMs: Int, totalBytesLoaded: Long, estimateBitrate: Long) { + this.estimateBitrate = estimateBitrate + this.totalLoadTime += totalLoadTimeMs.milliseconds + this.totalBytesLoaded += totalBytesLoaded + } + + fun setIsStall(isStall: Boolean) { + if (isStall) { + stallCount++ + totalStallTimeCounter.play() + } else { + totalStallTimeCounter.pause() + } + } + + fun setIsPlaying(playing: Boolean) { + if (playing) { + totalPlaytimeCounter.play() + } else { + totalPlaytimeCounter.pause() + } + } + + fun setRenderFirstFrameOrAudioPositionAdvancing() { + loadingTimes.state = currentPlaybackState + } + + fun setPlaybackState(state: Int) { + currentPlaybackState = state + if (drmSessionStartedCounter == 0) { + loadingTimes.state = state + } + when (state) { + Player.STATE_BUFFERING -> { + totalBufferingTimeCounter.play() + } + + Player.STATE_READY -> { + totalBufferingTimeCounter.pause() + } + } + } + + fun setLoadStarted(loadEventInfo: LoadEventInfo) { + this.url = loadEventInfo.uri + } + + /** + * Should be called when [AnalyticsListener.onLoadCompleted] is called + * + * @param loadEventInfo The [LoadEventInfo]. + * @param mediaLoadData The [MediaLoadData]. + */ + fun setLoadCompleted(loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + val loadDuration = loadEventInfo.loadDurationMs.milliseconds + this.url = loadEventInfo.uri + when (mediaLoadData.dataType) { + C.DATA_TYPE_DRM -> { + // FIXME Never called! + if (loadingTimes.drm == null) { + loadingTimes.drm = loadDuration + } + } + + C.DATA_TYPE_MANIFEST -> { + if (loadingTimes.manifest == null) { + loadingTimes.manifest = loadDuration + } + } + + C.DATA_TYPE_MEDIA -> { + if (loadingTimes.source == null) { + loadingTimes.source = loadDuration + } + } + + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { + if (loadingTimes.asset == null) { + loadingTimes.asset = loadDuration + } + } + + else -> { + } + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 6889a92eb..e08c08cda 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -73,7 +73,7 @@ internal class QoSCoordinator( override fun onMetricSessionFinished(metrics: PlaybackMetrics) { heartbeat.stop() sessionManager.getSessionById(metrics.sessionId)?.let { - sendEvent("END", it) + sendEndEvent(it, metrics) } ?: Log.wtf(TAG, "Should have a session!") } @@ -90,6 +90,16 @@ internal class QoSCoordinator( url = loadEventInfo.uri.toString() } + private fun sendEndEvent(session: PlaybackSessionManager.Session, playbackMetrics: PlaybackMetrics) { + val dataToSend = playbackMetrics.toQoSEvent() + val message = QoSMessage( + data = dataToSend, + eventName = "END", + sessionId = session.sessionId, + ) + messageHandler.sendEvent(message) + } + private fun sendEvent( eventName: String, session: PlaybackSessionManager.Session, @@ -105,17 +115,17 @@ internal class QoSCoordinator( } private fun PlaybackMetrics.toQoSEvent(): QoSEvent { - val bitrateBytes = bitrate / BITS - val bandwidthBytes = bandwidth / BITS + val bitrateBytes = indicatedBitrate / Byte.SIZE_BYTES + val bandwidthBytes = bandwidth / Byte.SIZE_BYTES return QoSEvent( bandwidth = bandwidthBytes, - bitrate = bitrateBytes, - bufferDuration = bufferDuration.inWholeMilliseconds, + bitrate = bitrateBytes.toInt(), + bufferDuration = player.totalBufferedDuration, playbackDuration = playbackDuration.inWholeMilliseconds, playerPosition = player.currentPosition, stallCount = stallCount, stallDuration = stallDuration.inWholeSeconds, - url = url, + url = url.toString(), ) } @@ -193,7 +203,6 @@ internal class QoSCoordinator( } private companion object { - private const val BITS = 8 private val HEARTBEAT_PERIOD = 10.seconds private const val TAG = "QoSCoordinator" } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt new file mode 100644 index 000000000..002c6ba1e --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import androidx.media3.common.Player +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.analytics.metrics.SessionMetrics +import io.mockk.clearAllMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SessionMetricsTest { + private lateinit var callback: (SessionMetrics) -> Unit + + @BeforeTest + fun setUp() { + callback = mockk<(SessionMetrics) -> Unit>(relaxed = true) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + } + + @Test + fun `test loading time ready with DRM with buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(false) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(5.seconds, metricSession.timeToReady) + assertEquals(11.seconds, metricSession.totalPlayingDuration) + assertEquals(3.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test loading time ready with multi DRM key`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(4.seconds, metricSession.timeToReady) + } + + @Test + fun `test loading time ready with DRM without buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertNull(metricSession.timeToReady) + assertEquals(12.seconds, metricSession.totalPlayingDuration) + assertEquals(0.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test loading time ready without DRM with buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(false) + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(2.seconds, metricSession.timeToReady) + assertEquals(1.seconds, metricSession.totalPlayingDuration) + assertEquals(2.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test stall count and duration`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setIsStall(true) + advanceTimeBy(1.seconds) + metricSession.setIsStall(false) + advanceTimeBy(10.seconds) + metricSession.setIsStall(true) + advanceTimeBy(1.seconds) + metricSession.setIsStall(false) + advanceTimeBy(10.seconds) + + assertEquals(2, metricSession.stallCount) + assertEquals(2.seconds, metricSession.totalStallDuration) + } +} From b24b69fd60567745ae5f40d2a87f8b9105e7a37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 25 Jul 2024 17:44:46 +0200 Subject: [PATCH 11/17] Create line and bar chart components for the demo (#645) --- pillarbox-demo-shared/build.gradle.kts | 1 + .../demo/shared/ui/components/Charts.kt | 486 ++++++++++++++++++ pillarbox-player/build.gradle.kts | 2 +- 3 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts index 5ad7ae4dd..422fb9a2d 100644 --- a/pillarbox-demo-shared/build.gradle.kts +++ b/pillarbox-demo-shared/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { api(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.geometry) api(libs.androidx.compose.ui.graphics) + api(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.unit) api(libs.androidx.lifecycle.viewmodel) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt new file mode 100644 index 000000000..8cb511904 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt @@ -0,0 +1,486 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ + +package ch.srgssr.pillarbox.demo.shared.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import java.text.NumberFormat +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Display a line chart from the provided set of values. + * + * @param data The list of values to draw. + * @param modifier The [Modifier] to apply to the composable. You must use this [Modifier] to specify the size of this chart. + * Either with exact values (for example, with `Modifier.size()`), or relative to its parent (for example, with `Modifier.fillMaxSize()`). + * @param lineColor The color of the line. + * @param lineWidth The width of the line. + * @param lineCornerRadius The radius of the line corners. + * @param stretchChartToPointsCount The number of points to display on the chart. + * If `null`, all the provided points are drawn. + * If [data] has fewer points than [stretchChartToPointsCount], an empty space will be reserved for the missing points. + * Otherwise, the last [stretchChartToPointsCount] values from [data] will be drawn, filling the whole width. + * @param scaleItemsCount The number of values to display on the vertical axis. + * @param scaleTextFormatter The formatter used to format each value of the vertical axis. + * @param scaleTextStyle The text style to apply on each value of the vertical axis. + * @param scaleTextHorizontalPadding The horizontal padding to apply on each value of the vertical axis. + * @param scaleLineColor The color of the horizontal line next to each value of the vertical axis. + */ +@Composable +fun LineChart( + data: List, + modifier: Modifier, + lineColor: Color = Color.Red, + lineWidth: Dp = 2.dp, + lineCornerRadius: Dp = 6.dp, + stretchChartToPointsCount: Int? = null, + scaleItemsCount: Int = 4, + scaleTextFormatter: NumberFormat = NumberFormat.getIntegerInstance(), + scaleTextStyle: TextStyle = TextStyle.Default, + scaleTextHorizontalPadding: Dp = 8.dp, + scaleLineColor: Color = Color.LightGray, +) { + Chart( + data = data, + modifier = modifier, + stretchChartToPointsCount = stretchChartToPointsCount, + scaleItemsCount = scaleItemsCount, + scaleTextFormatter = scaleTextFormatter, + scaleTextStyle = scaleTextStyle, + scaleTextHorizontalPadding = scaleTextHorizontalPadding, + scaleLineColor = scaleLineColor, + drawChart = { points, maxValue, bounds -> + drawLineChart( + points = points, + bounds = bounds, + maxValue = maxValue, + lineColor = lineColor, + lineWidth = lineWidth, + lineCornerRadius = lineCornerRadius, + maxPoints = stretchChartToPointsCount ?: points.size, + ) + }, + ) +} + +/** + * Display a bar chart from the provided set of values. + * + * @param data The list of values to draw. + * @param modifier The [Modifier] to apply to the composable. You must use this [Modifier] to specify the size of this chart. + * Either with exact values (for example, with `Modifier.size()`), or relative to its parent (for example, with `Modifier.fillMaxSize()`). + * @param barColor The color of each bar. + * @param barSpacing The spacing between two bars. + * @param stretchChartToPointsCount The number of points to display on the chart. + * If `null`, all the provided points are drawn. + * If [data] has fewer points than [stretchChartToPointsCount], an empty space will be reserved for the missing points. + * Otherwise, the last [stretchChartToPointsCount] values from [data] will be drawn, filling the whole width. + * @param scaleItemsCount The number of values to display on the vertical axis. + * @param scaleTextFormatter The formatter used to format each value of the vertical axis. + * @param scaleTextStyle The text style to apply on each value of the vertical axis. + * @param scaleTextHorizontalPadding The horizontal padding to apply on each value of the vertical axis. + * @param scaleLineColor The color of the horizontal line next to each value of the vertical axis. + */ +@Composable +fun BarChart( + data: List, + modifier: Modifier, + barColor: Color = Color.Blue, + barSpacing: Dp = 1.dp, + stretchChartToPointsCount: Int? = null, + scaleItemsCount: Int = 4, + scaleTextFormatter: NumberFormat = NumberFormat.getIntegerInstance(), + scaleTextStyle: TextStyle = TextStyle.Default, + scaleTextHorizontalPadding: Dp = 8.dp, + scaleLineColor: Color = Color.LightGray, +) { + Chart( + data = data, + modifier = modifier, + stretchChartToPointsCount = stretchChartToPointsCount, + scaleItemsCount = scaleItemsCount, + scaleTextFormatter = scaleTextFormatter, + scaleTextStyle = scaleTextStyle, + scaleTextHorizontalPadding = scaleTextHorizontalPadding, + scaleLineColor = scaleLineColor, + drawChart = { points, maxValue, bounds -> + drawBarChart( + points = points, + bounds = bounds, + maxValue = maxValue, + barColor = barColor, + barSpacing = barSpacing, + maxPoints = stretchChartToPointsCount ?: points.size, + ) + }, + ) +} + +@Composable +private fun Chart( + data: List, + modifier: Modifier = Modifier, + stretchChartToPointsCount: Int? = null, + scaleItemsCount: Int = 4, + scaleTextFormatter: NumberFormat, + scaleTextStyle: TextStyle = TextStyle.Default, + scaleTextHorizontalPadding: Dp = 8.dp, + scaleLineColor: Color = Color.LightGray, + drawChart: DrawScope.(points: List, maxValue: Int, bounds: Rect) -> Unit, +) { + val trimmedData = if (stretchChartToPointsCount != null) data.takeLast(stretchChartToPointsCount) else data + if (trimmedData.isEmpty()) { + return + } + + val textMeasurer = rememberTextMeasurer() + + val maxValue = trimmedData.max() + val numberOfDigitsInMaxValue = log10(abs(maxValue.toDouble())).toInt() + val increment = 10.0.pow(numberOfDigitsInMaxValue).toInt() + + var nextMaxMultipleOfScales = (increment * (floor(maxValue / increment) + 1)).toInt() + while (nextMaxMultipleOfScales % (scaleItemsCount - 1) != 0) { + nextMaxMultipleOfScales += increment + } + + Canvas(modifier = modifier) { + val maxScaleWidth = textMeasurer.measure(scaleTextFormatter.format(nextMaxMultipleOfScales), scaleTextStyle).size.width + val chartBounds = Rect( + offset = Offset.Zero, + size = Size( + width = size.width - maxScaleWidth - scaleTextHorizontalPadding.toPx() * 2f, + height = size.height, + ), + ) + + drawChart(trimmedData, nextMaxMultipleOfScales, chartBounds) + + drawScale( + textMeasurer = textMeasurer, + maxValue = nextMaxMultipleOfScales, + scaleItemsCount = scaleItemsCount, + scaleTextFormatter = scaleTextFormatter, + scaleTextStyle = scaleTextStyle, + scaleTextHorizontalPadding = scaleTextHorizontalPadding, + scaleLineColor = scaleLineColor, + ) + } +} + +private fun DrawScope.drawLineChart( + points: List, + bounds: Rect, + maxValue: Int, + lineColor: Color, + lineWidth: Dp, + lineCornerRadius: Dp, + maxPoints: Int, +) { + fun getX(index: Int): Float { + return (index / (maxPoints - 1f)) * bounds.width + } + + fun getY(value: Float): Float { + return (1 - (value / maxValue.toFloat())) * bounds.height + } + + val path = Path() + path.moveTo(getX(0), getY(points[0])) + + for (index in 1 until points.size) { + path.lineTo(getX(index), getY(points[index])) + } + + drawPath( + path = path, + color = lineColor, + style = Stroke( + width = lineWidth.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.cornerPathEffect(lineCornerRadius.toPx()), + ), + ) +} + +private fun DrawScope.drawBarChart( + points: List, + bounds: Rect, + maxValue: Int, + barColor: Color, + barSpacing: Dp, + maxPoints: Int, +) { + val barSpacingPx = barSpacing.toPx() + val barWidth = (bounds.width - barSpacingPx * (maxPoints - 1)) / maxPoints + + points.forEachIndexed { index, point -> + val x = (index / maxPoints) * bounds.width + (barSpacingPx + barWidth) * index + val y = (1f - (point / maxValue.toFloat())) * bounds.height + + drawRect( + color = barColor, + topLeft = Offset( + x = x, + y = y, + ), + size = Size( + width = barWidth, + height = bounds.height - y, + ), + ) + } +} + +private fun DrawScope.drawScale( + textMeasurer: TextMeasurer, + maxValue: Int, + scaleItemsCount: Int, + scaleTextFormatter: NumberFormat, + scaleTextStyle: TextStyle, + scaleTextHorizontalPadding: Dp, + scaleLineColor: Color, +) { + val step = (maxValue / (scaleItemsCount - 1f)).toInt() + + repeat(scaleItemsCount) { index -> + val scale = scaleTextFormatter.format(index * step) + val textSize = textMeasurer.measure(scale, scaleTextStyle).size + val lineXEnd = size.width - textSize.width - scaleTextHorizontalPadding.toPx() * 2f + val lineY = ((scaleItemsCount - 1f - index) / (scaleItemsCount - 1f)) * size.height + val textX = lineXEnd + scaleTextHorizontalPadding.toPx() + val textY = (lineY - textSize.center.y).coerceIn( + minimumValue = 0f, + maximumValue = size.height - textSize.height, + ) + + drawLine( + color = scaleLineColor, + start = Offset( + x = 0f, + y = lineY, + ), + end = Offset( + x = lineXEnd, + y = lineY, + ), + ) + + drawText( + textMeasurer = textMeasurer, + text = scale, + topLeft = Offset( + x = textX, + y = textY, + ), + style = scaleTextStyle, + ) + } +} + +@Composable +@Preview(showBackground = true, group = "Line chart") +private fun LineChartPreview() { + LineChart( + data = generateRandomPreviewData( + dataSize = 90, + initialValueRange = 0..10, + nextItemVariation = -0.3..0.3, + ), + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +@Preview(showBackground = true, group = "Line chart") +private fun LineChartLiveDataPreview() { + val dataSize = 90 + + LineChart( + data = generateRandomPreviewLiveData( + dataSize = dataSize, + initialValueRange = 0..10, + nextItemVariation = -0.3..0.3, + refreshInterval = 1.seconds, + ), + modifier = Modifier.fillMaxSize(), + stretchChartToPointsCount = dataSize, + ) +} + +@Composable +@Preview(showBackground = true, group = "Bar chart") +private fun BarChartsPreview() { + BarChart( + data = generateRandomPreviewData( + dataSize = 90, + initialValueRange = 10..1000, + nextItemVariation = -10.0..10.0, + ), + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +@Preview(showBackground = true, group = "Bar chart") +private fun BarChartsLiveDataPreview() { + val dataSize = 90 + + BarChart( + data = generateRandomPreviewLiveData( + dataSize = dataSize, + initialValueRange = 10..1000, + nextItemVariation = -10.0..10.0, + refreshInterval = 1.seconds, + ), + modifier = Modifier.fillMaxSize(), + stretchChartToPointsCount = dataSize, + ) +} + +@Composable +@Preview(showBackground = true, group = "Combined chars") +private fun CombinedChartsPreview() { + val dataSize = 90 + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LineChart( + data = generateRandomPreviewData( + dataSize = dataSize, + initialValueRange = 0..10, + nextItemVariation = -0.3..0.3, + ), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + BarChart( + data = generateRandomPreviewData( + dataSize = dataSize, + initialValueRange = 100..1000, + nextItemVariation = -10.0..10.0, + ), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + } +} + +@Composable +@Preview(showBackground = true, group = "Combined chars") +private fun CombinedChartsLiveDataPreview() { + val dataSize = 90 + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + LineChart( + data = generateRandomPreviewLiveData( + dataSize = dataSize, + initialValueRange = 0..10, + nextItemVariation = -0.3..0.3, + refreshInterval = 1.seconds, + ), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + stretchChartToPointsCount = dataSize, + ) + + BarChart( + data = generateRandomPreviewLiveData( + dataSize = dataSize, + initialValueRange = 100..1000, + nextItemVariation = -10.0..10.0, + refreshInterval = 1.seconds, + ), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + stretchChartToPointsCount = dataSize, + ) + } +} + +@Suppress("MagicNumber") +private fun generateRandomPreviewData( + @Suppress("SameParameterValue") dataSize: Int, + initialValueRange: IntRange, + nextItemVariation: ClosedRange, +): List { + return (0 until dataSize).runningFold(Random.nextInt(initialValueRange.first, initialValueRange.last).toFloat()) { acc, _ -> + if (Random.nextInt(5) < 2) { + acc + } else { + (acc + Random.nextDouble(nextItemVariation.start, nextItemVariation.endInclusive).toFloat()).coerceAtLeast(0f) + } + }.drop(1) +} + +@Composable +private fun generateRandomPreviewLiveData( + @Suppress("SameParameterValue") dataSize: Int, + initialValueRange: IntRange, + nextItemVariation: ClosedRange, + refreshInterval: Duration, +): List { + val data = remember { + mutableStateListOf(Random.nextInt(initialValueRange.first, initialValueRange.last).toFloat()) + } + + LaunchedEffect(Unit) { + while (true) { + val newValue = if (Random.nextInt(5) < 2) { + data.last() + } else { + (data.last() + Random.nextDouble(nextItemVariation.start, nextItemVariation.endInclusive).toFloat()).coerceAtLeast(0f) + } + + data.add(newValue) + data.removeIf { data.size > dataSize } + + delay(refreshInterval) + } + } + + return data +} diff --git a/pillarbox-player/build.gradle.kts b/pillarbox-player/build.gradle.kts index afa0657d7..9318eabb2 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -57,7 +57,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.mockk.dsl) - testImplementation(libs.robolectric) + testRuntimeOnly(libs.robolectric) testImplementation(libs.robolectric.annotations) testImplementation(libs.robolectric.shadows.framework) testImplementation(libs.turbine) From 89a87bcc54bd7115d9b86238151c7b23a1eb00a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Mon, 29 Jul 2024 15:02:54 +0200 Subject: [PATCH 12/17] Remove usage of `List.removeIf` (#657) --- .../ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt index 8cb511904..5dee9acb1 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/components/Charts.kt @@ -475,8 +475,10 @@ private fun generateRandomPreviewLiveData( (data.last() + Random.nextDouble(nextItemVariation.start, nextItemVariation.endInclusive).toFloat()).coerceAtLeast(0f) } - data.add(newValue) - data.removeIf { data.size > dataSize } + val newData = (data + newValue).takeLast(dataSize) + + data.clear() + data.addAll(newData) delay(refreshInterval) } From 09eb787b9c3c2966a8e8522482ee263aa6a3e577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 30 Jul 2024 09:17:24 +0200 Subject: [PATCH 13/17] Metrics collector UI (#655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller --- gradle/libs.versions.toml | 2 + pillarbox-demo-shared/build.gradle.kts | 1 + .../demo/shared/ui/HomeDestination.kt | 6 + .../demo/shared/ui/NavigationRoutes.kt | 1 + .../demo/shared/ui/settings/AppSettings.kt | 47 +++ .../ui/settings/AppSettingsRepository.kt | 108 +++++++ .../ui/settings/AppSettingsViewModel.kt | 67 ++++ .../ui/settings/MetricsOverlayOptions.kt | 20 ++ .../srgssr/pillarbox/demo/MainNavigation.kt | 21 +- .../demo/ui/examples/ExamplesHome.kt | 17 +- .../demo/ui/player/DemoPlayerView.kt | 17 +- .../pillarbox/demo/ui/player/PlayerView.kt | 28 ++ .../demo/ui/player/metrics/MetricsOverlay.kt | 130 ++++++++ .../demo/ui/settings/AppSettingsView.kt | 300 ++++++++++++++++++ .../CustomPlaybackSettingsShowcase.kt | 53 +++- .../src/main/res/values/strings.xml | 6 + .../analytics/metrics/SessionMetrics.kt | 2 +- .../pillarbox/player/qos/QoSCoordinator.kt | 5 +- .../pillarbox/player/utils/BitrateUtil.kt | 25 ++ 19 files changed, 820 insertions(+), 36 deletions(-) create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt create mode 100644 pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt create mode 100644 pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c3e2a88d..87bd667be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ androidx-activity = "1.9.0" androidx-annotation = "1.8.0" androidx-compose = "2024.06.00" androidx-core = "1.13.1" +androidx-datastore = "1.1.1" androidx-fragment = "1.8.2" androidx-lifecycle = "2.8.3" androidx-media3 = "1.4.0" @@ -45,6 +46,7 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "androidx-fragment" } androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" } diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts index 422fb9a2d..c70f8ae58 100644 --- a/pillarbox-demo-shared/build.gradle.kts +++ b/pillarbox-demo-shared/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.unit) + api(libs.androidx.datastore.preferences) api(libs.androidx.lifecycle.viewmodel) api(libs.androidx.media3.common) implementation(libs.androidx.media3.exoplayer) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt index d3f1db2e0..d2438a0ed 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination @@ -46,6 +47,11 @@ sealed class HomeDestination( * Info home page */ data object Search : HomeDestination(NavigationRoutes.searchHome, R.string.search, Icons.Default.Search) + + /** + * Settings home page + */ + data object Settings : HomeDestination(NavigationRoutes.settingsHome, R.string.settings, Icons.Default.Settings) } /** diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt index e2d656937..e3455a681 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt @@ -30,4 +30,5 @@ object NavigationRoutes { const val contentLists = "content_lists" const val contentList = "content_list" const val searchHome = "search_home" + const val settingsHome = "settings_home" } diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt new file mode 100644 index 000000000..6e5fd3ea9 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettings.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * App settings + * + * @property metricsOverlayEnabled + * @property metricsOverlayTextSize + * @property metricsOverlayTextColor + */ +class AppSettings( + val metricsOverlayEnabled: Boolean = false, + val metricsOverlayTextSize: TextSize = TextSize.Medium, + val metricsOverlayTextColor: TextColor = TextColor.Yellow, +) { + + /** + * Text size + * + * @property size the [TextUnit]. + */ + enum class TextSize(val size: TextUnit) { + Small(8.sp), + Medium(12.sp), + Large(18.sp), + } + + /** + * Text color + * + * @property color the [Color]. + */ + enum class TextColor(val color: Color) { + Yellow(Color.Yellow), + Red(Color.Red), + Green(Color.Green), + Blue(Color.Blue), + White(Color.White) + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt new file mode 100644 index 000000000..88b95f0ba --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsRepository.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings.TextColor +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings.TextSize +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +private val Context.dataStore by preferencesDataStore(name = "settings") + +/** + * App settings repository + * @param context The context. + */ +class AppSettingsRepository(context: Context) { + private val dataStore = context.dataStore + + /** + * Get app settings + * + * @return + */ + fun getAppSettings(): Flow { + return dataStore.data + .catch { + if (it is IOException) { + emit(emptyPreferences()) + } else { + throw it + } + } + .map { preferences -> + AppSettings( + metricsOverlayTextSize = preferences.getEnum(PreferencesKeys.METRICS_OVERLAY_TEXT_SIZE, TextSize.Medium), + metricsOverlayTextColor = preferences.getEnum(PreferencesKeys.METRICS_OVERLAY_TEXT_COLOR, TextColor.Yellow), + metricsOverlayEnabled = preferences[PreferencesKeys.METRICS_OVERLAY_ENABLED] ?: false, + ) + } + } + + /** + * Set metrics overlay enabled + * + * @param enabled + */ + suspend fun setMetricsOverlayEnabled(enabled: Boolean) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_ENABLED] = enabled + } + } + + /** + * Set metrics overlay text color + * + * @param textColor + */ + suspend fun setMetricsOverlayTextColor(textColor: TextColor) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_TEXT_COLOR] = textColor.name + } + } + + /** + * Set metrics overlay text size + * + * @param textSize + */ + suspend fun setMetricsOverlayTextSize(textSize: TextSize) { + dataStore.edit { + it[PreferencesKeys.METRICS_OVERLAY_TEXT_SIZE] = textSize.name + } + } + + private object PreferencesKeys { + val METRICS_OVERLAY_ENABLED = booleanPreferencesKey("metrics_overlay_enabled") + val METRICS_OVERLAY_TEXT_COLOR = stringPreferencesKey("metrics_overlay_text_color") + val METRICS_OVERLAY_TEXT_SIZE = stringPreferencesKey("metrics_overlay_text_size") + } + + private companion object { + private const val TAG = "AppSettingsRepository" + + private inline fun > Preferences.getEnum( + key: Preferences.Key, + defaultValue: T, + ): T { + return try { + get(key)?.let { enumValueOf(it) } ?: defaultValue + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Can't parse enum value", e) + defaultValue + } + } + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt new file mode 100644 index 000000000..f72bb9be0 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/AppSettingsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +/** + * App settings view model + * + * @param appSettingsRepository + */ +class AppSettingsViewModel(private val appSettingsRepository: AppSettingsRepository) : ViewModel() { + + /** + * Current app settings + */ + val currentAppSettings = appSettingsRepository.getAppSettings() + + /** + * Set metrics overlay enabled + * + * @param enabled + */ + fun setMetricsOverlayEnabled(enabled: Boolean) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayEnabled(enabled) + } + } + + /** + * Set metrics overlay text color + * + * @param textColor + */ + fun setMetricsOverlayTextColor(textColor: AppSettings.TextColor) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayTextColor(textColor) + } + } + + /** + * Set metrics overlay text size + * + * @param textSize + */ + fun setMetricsOverlayTextSize(textSize: AppSettings.TextSize) { + viewModelScope.launch { + appSettingsRepository.setMetricsOverlayTextSize(textSize) + } + } + + /** + * Factory + * + * @param appSettingsRepository + */ + class Factory(private val appSettingsRepository: AppSettingsRepository) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return AppSettingsViewModel(appSettingsRepository) as T + } + } +} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt new file mode 100644 index 000000000..9073e9f91 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/settings/MetricsOverlayOptions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.ui.settings + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit + +/** + * Metrics overlay options + * + * @property textColor The [Color] for the text overlay. + * @property textSize The [TextUnit] for the text overlay. + */ + +data class MetricsOverlayOptions( + val textColor: Color = Color.Yellow, + val textSize: TextUnit = TextUnit.Unspecified, +) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt index 7b107ee22..c2bedf8ba 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt @@ -59,22 +59,30 @@ import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.shared.ui.navigate +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsRepository +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsViewModel import ch.srgssr.pillarbox.demo.ui.examples.ExamplesHome import ch.srgssr.pillarbox.demo.ui.lists.listsNavGraph import ch.srgssr.pillarbox.demo.ui.player.SimplePlayerActivity import ch.srgssr.pillarbox.demo.ui.search.SearchHome +import ch.srgssr.pillarbox.demo.ui.settings.AppSettingsView import ch.srgssr.pillarbox.demo.ui.showcases.showcasesNavGraph import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings import java.net.URL -private val bottomNavItems = listOf(HomeDestination.Examples, HomeDestination.ShowCases, HomeDestination.Lists, HomeDestination.Search) +private val bottomNavItems = + listOf(HomeDestination.Examples, HomeDestination.ShowCases, HomeDestination.Lists, HomeDestination.Search, HomeDestination.Settings) private val topLevelRoutes = - listOf(HomeDestination.Examples.route, NavigationRoutes.showcaseList, NavigationRoutes.contentLists, HomeDestination.Search.route) + listOf( + HomeDestination.Examples.route, NavigationRoutes.showcaseList, NavigationRoutes.contentLists, HomeDestination.Search.route, + HomeDestination.Settings.route + ) /** * Main view with all the navigation */ +@Suppress("StringLiteralDuplication") @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainNavigation() { @@ -135,6 +143,15 @@ fun MainNavigation() { listsNavGraph(navController, ilRepository, ilHost) } + composable(route = HomeDestination.Settings.route, DemoPageView("home", listOf("app", "pillarbox", "settings"))) { + val appSettingsRepository = remember(context) { + AppSettingsRepository(context) + } + + val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = AppSettingsViewModel.Factory(appSettingsRepository)) + AppSettingsView(appSettingsViewModel) + } + composable(route = NavigationRoutes.searchHome, DemoPageView("home", listOf("app", "pillarbox", "search"))) { val ilRepository = PlayerModule.createIlRepository(context) val viewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory(ilRepository)) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt index 32bc1eb5f..323f97842 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/examples/ExamplesHome.kt @@ -13,16 +13,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel -import ch.srgssr.pillarbox.demo.BuildConfig import ch.srgssr.pillarbox.demo.shared.data.DemoItem import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.ui.examples.ExamplesViewModel @@ -55,10 +52,7 @@ private fun ListStreamView( onItemClicked: (item: DemoItem) -> Unit ) { LazyColumn( - contentPadding = PaddingValues( - horizontal = MaterialTheme.paddings.baseline, - vertical = MaterialTheme.paddings.small - ), + contentPadding = PaddingValues(MaterialTheme.paddings.baseline), verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small), ) { item(contentType = "url_urn_input") { @@ -96,15 +90,6 @@ private fun ListStreamView( } } } - - item(contentType = "app_version") { - Text( - text = BuildConfig.VERSION_NAME, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium - ) - } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt index aadbdcbee..34854abfe 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/DemoPlayerView.kt @@ -18,10 +18,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsRepository +import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.components.ShowSystemUi import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerBottomToolbar import ch.srgssr.pillarbox.demo.ui.player.playlist.PlaylistView @@ -101,6 +106,11 @@ private fun PlayerContent( val fullScreenToggle: (Boolean) -> Unit = { fullScreenEnabled -> fullScreenState = fullScreenEnabled } + val context = LocalContext.current + val appSettingsRepository = remember { + AppSettingsRepository(context) + } + val appSettings by appSettingsRepository.getAppSettings().collectAsStateWithLifecycle(AppSettings()) ShowSystemUi(isShowed = !fullScreenState) Column(modifier = Modifier.fillMaxSize()) { var pinchScaleMode by remember(fullScreenState) { @@ -127,7 +137,12 @@ private fun PlayerContent( player = player, controlsToggleable = !pictureInPicture, controlsVisible = !pictureInPicture, - scaleMode = pinchScaleMode + scaleMode = pinchScaleMode, + overlayEnabled = appSettings.metricsOverlayEnabled, + overlayOptions = MetricsOverlayOptions( + textColor = appSettings.metricsOverlayTextColor.color, + textSize = appSettings.metricsOverlayTextSize.size + ), ) { PlayerBottomToolbar( modifier = Modifier.fillMaxWidth(), diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt index e9eb2b610..7917e1b2c 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/PlayerView.kt @@ -20,13 +20,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.Player +import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerControls import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerError import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerNoContent import ch.srgssr.pillarbox.demo.ui.player.controls.SkipButton import ch.srgssr.pillarbox.demo.ui.player.controls.rememberProgressTrackerState +import ch.srgssr.pillarbox.demo.ui.player.metrics.MetricsOverlay import ch.srgssr.pillarbox.demo.ui.theme.paddings +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.currentPositionAsFlow import ch.srgssr.pillarbox.ui.ProgressTrackerState import ch.srgssr.pillarbox.ui.ScaleMode import ch.srgssr.pillarbox.ui.exoplayer.ExoPlayerSubtitleView @@ -38,6 +43,8 @@ import ch.srgssr.pillarbox.ui.widget.ToggleableBox import ch.srgssr.pillarbox.ui.widget.keepScreenOn import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds /** * Simple player view @@ -48,6 +55,8 @@ import ch.srgssr.pillarbox.ui.widget.rememberDelayedVisibilityState * @param controlsVisible The control visibility. * @param controlsToggleable The controls are toggleable. * @param progressTracker The progress tracker. + * @param overlayOptions The [MetricsOverlayOptions]. + * @param overlayEnabled true to display the metrics overlay. * @param content The action to display under the slider. */ @Composable @@ -58,6 +67,8 @@ fun PlayerView( controlsVisible: Boolean = true, controlsToggleable: Boolean = true, progressTracker: ProgressTrackerState = rememberProgressTrackerState(player = player, smoothTracker = true), + overlayOptions: MetricsOverlayOptions = MetricsOverlayOptions(), + overlayEnabled: Boolean = false, content: @Composable ColumnScope.() -> Unit = {}, ) { val playerError by player.playerErrorAsState() @@ -116,6 +127,23 @@ fun PlayerView( } } ExoPlayerSubtitleView(player = player) + if (overlayEnabled && player is PillarboxExoPlayer) { + val currentMetricsFlow = remember(player) { + player.currentPositionAsFlow(updateInterval = 500.milliseconds).map { + player.getCurrentMetrics() + } + } + val currentMetrics by currentMetricsFlow.collectAsStateWithLifecycle(player.getCurrentMetrics()) + currentMetrics?.let { + MetricsOverlay( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart), + playbackMetrics = it, + overlayOptions = overlayOptions, + ) + } + } } if (currentCredit != null && !visibilityState.isVisible) { diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt new file mode 100644 index 000000000..857ed2d17 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/metrics/MetricsOverlay.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.player.metrics + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import androidx.media3.common.Format +import ch.srgssr.pillarbox.demo.shared.ui.settings.MetricsOverlayOptions +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate + +/** + * Display [playbackMetrics] as overlay. + * + * @param playbackMetrics The [PlaybackMetrics] to display. + * @param overlayOptions The [MetricsOverlayOptions] the options. + * @param modifier The modifier to be applied to the layout. + */ +@Composable +fun MetricsOverlay( + playbackMetrics: PlaybackMetrics, + overlayOptions: MetricsOverlayOptions, + modifier: Modifier = Modifier, +) { + val currentVideoFormat = playbackMetrics.videoFormat + val currentAudioFormat = playbackMetrics.audioFormat + Column(modifier = modifier) { + currentVideoFormat?.let { + OverlayText( + overlayOptions = overlayOptions, + text = "video format codecs:${it.codecs} ${it.bitrate.toByteRate()}Bps frame-rate:${it.frameRate}" + ) + } + currentAudioFormat?.let { + OverlayText( + overlayOptions = overlayOptions, + text = "audio format codes:${it.codecs} ${it.bitrate.toByteRate()}Bps channels=${it.channelCount} sample-rate:${it.sampleRate}Hz" + ) + } + + val averageBitRateString = StringBuilder("average bitrate ") + currentVideoFormat?.getAverageBitrateOrNull()?.let { + averageBitRateString.append("video:${it.toByteRate()}Bps ") + } + currentAudioFormat?.getAverageBitrateOrNull()?.let { + averageBitRateString.append("audio:${it.toByteRate()}Bps") + } + OverlayText(text = averageBitRateString.toString(), overlayOptions = overlayOptions) + + val peekBitrateString = StringBuilder("peek bitrate ") + currentVideoFormat?.getPeekBitrateOrNull()?.let { + peekBitrateString.append("video:${it.toByteRate()}Bps ") + } + currentAudioFormat?.getPeekBitrateOrNull()?.let { + peekBitrateString.append("audio:${it.toByteRate()}Bps") + } + OverlayText(text = peekBitrateString.toString(), overlayOptions = overlayOptions) + + OverlayText( + overlayOptions = overlayOptions, + text = "indicated bitrate: ${playbackMetrics.indicatedBitrate.toByteRate()}Bps" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "bandwidth ${playbackMetrics.bandwidth.toByteRate()}Bps" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "asset: ${playbackMetrics.loadDuration.asset}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "drm: ${playbackMetrics.loadDuration.drm}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "manifest: ${playbackMetrics.loadDuration.manifest}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "source: ${playbackMetrics.loadDuration.source}" + ) + OverlayText( + overlayOptions = overlayOptions, + text = "timeToReady: ${playbackMetrics.loadDuration.timeToReady}" + ) + + OverlayText( + overlayOptions = overlayOptions, + text = "playtime: ${playbackMetrics.playbackDuration}" + ) + } +} + +@Composable +private fun OverlayText( + text: String, + overlayOptions: MetricsOverlayOptions, + modifier: Modifier = Modifier +) { + BasicText( + modifier = modifier, + style = TextStyle.Default.copy(fontSize = overlayOptions.textSize), + color = { overlayOptions.textColor }, + text = text, + ) +} + +@Preview +@Composable +private fun OverlayTextPreview() { + val overlayOptions = MetricsOverlayOptions(textColor = Color.Yellow, textSize = 12.sp) + OverlayText(text = "Text; 12 ac1.mp3 channels:4 colors:4", overlayOptions = overlayOptions) +} + +private fun Format.getPeekBitrateOrNull(): Int? { + return if (peakBitrate == Format.NO_VALUE) null else peakBitrate +} + +private fun Format.getAverageBitrateOrNull(): Int? { + return if (averageBitrate == Format.NO_VALUE) null else averageBitrate +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt new file mode 100644 index 000000000..c8a5afdaf --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/settings/AppSettingsView.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.MediaLibraryInfo +import ch.srgssr.pillarbox.demo.BuildConfig +import ch.srgssr.pillarbox.demo.R +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettings +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsRepository +import ch.srgssr.pillarbox.demo.shared.ui.settings.AppSettingsViewModel +import ch.srgssr.pillarbox.demo.ui.components.DemoListHeaderView +import ch.srgssr.pillarbox.demo.ui.components.DemoListSectionView +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme +import ch.srgssr.pillarbox.demo.ui.theme.paddings + +/** + * App settings view + * + * @param settingsViewModel The [AppSettingsViewModel] + * @param modifier The [Modifier] to apply to the layout. + */ +@Composable +fun AppSettingsView( + settingsViewModel: AppSettingsViewModel, + modifier: Modifier = Modifier, +) { + val appSettings by settingsViewModel.currentAppSettings.collectAsStateWithLifecycle(AppSettings()) + + Column( + modifier = modifier + .padding(horizontal = MaterialTheme.paddings.baseline) + .padding(bottom = MaterialTheme.paddings.baseline) + .verticalScroll(rememberScrollState()), + ) { + MetricsOverlaySettings( + appSettings = appSettings, + setMetricsOverlayTextSize = settingsViewModel::setMetricsOverlayTextSize, + setMetricsOverlayEnabled = settingsViewModel::setMetricsOverlayEnabled, + setMetricsOverlayTextColor = settingsViewModel::setMetricsOverlayTextColor, + ) + + LibraryVersionSection() + } +} + +@Composable +private fun MetricsOverlaySettings( + appSettings: AppSettings, + setMetricsOverlayEnabled: (Boolean) -> Unit, + setMetricsOverlayTextColor: (AppSettings.TextColor) -> Unit, + setMetricsOverlayTextSize: (AppSettings.TextSize) -> Unit, +) { + SettingSection(title = stringResource(R.string.setting_metrics_overlay)) { + TextLabel(text = stringResource(R.string.settings_enabled_overlay_description)) + + LabeledSwitch( + text = stringResource(R.string.settings_enabled_metrics_overlay), + checked = appSettings.metricsOverlayEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.paddings.small), + onCheckedChange = setMetricsOverlayEnabled, + ) + + AnimatedVisibility(visible = appSettings.metricsOverlayEnabled) { + Column { + DropdownSetting( + text = stringResource(R.string.settings_choose_text_color), + entries = AppSettings.TextColor.entries, + selectedEntry = appSettings.metricsOverlayTextColor, + modifier = Modifier.fillMaxWidth(), + onEntrySelected = setMetricsOverlayTextColor, + ) + + DropdownSetting( + text = stringResource(R.string.settings_choose_text_size), + entries = AppSettings.TextSize.entries, + selectedEntry = appSettings.metricsOverlayTextSize, + modifier = Modifier.fillMaxWidth(), + onEntrySelected = setMetricsOverlayTextSize, + ) + } + } + } +} + +@Composable +private fun LibraryVersionSection() { + SettingSection(title = stringResource(R.string.settings_library_version)) { + TextLabel( + text = "Pillarbox: ${BuildConfig.VERSION_NAME}", + modifier = Modifier.padding(vertical = MaterialTheme.paddings.small), + ) + + HorizontalDivider() + + TextLabel( + text = "Media3: ${MediaLibraryInfo.VERSION}", + modifier = Modifier.padding(vertical = MaterialTheme.paddings.small), + ) + } +} + +@Composable +private fun SettingSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + DemoListHeaderView( + title = title, + modifier = Modifier.padding(start = MaterialTheme.paddings.baseline) + ) + + DemoListSectionView(content = content) +} + +@Composable +private fun TextLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.padding( + horizontal = MaterialTheme.paddings.baseline, + vertical = MaterialTheme.paddings.small + ), + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun LabeledSwitch( + text: String, + checked: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = modifier + .clickable { onCheckedChange(!checked) } + .minimumInteractiveComponentSize() + .padding(end = MaterialTheme.paddings.baseline), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextLabel(text = text) + + Switch( + checked = checked, + onCheckedChange = null, + ) + } +} + +@Composable +private fun DropdownSetting( + text: String, + entries: List, + selectedEntry: T, + modifier: Modifier = Modifier, + onEntrySelected: (entry: T) -> Unit, +) { + var dropdownOffset by remember { mutableStateOf(DpOffset.Zero) } + var showDropdownMenu by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + + Box(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressInteraction = PressInteraction.Press(it) + + interactionSource.emit(pressInteraction) + + dropdownOffset = DpOffset( + x = it.x.toDp(), + y = (it.y - size.height).toDp(), + ) + showDropdownMenu = true + + if (tryAwaitRelease()) { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } else { + interactionSource.emit(PressInteraction.Cancel(pressInteraction)) + } + } + ) + } + .indication( + interactionSource = interactionSource, + indication = LocalIndication.current, + ) + .minimumInteractiveComponentSize() + .padding(end = MaterialTheme.paddings.baseline), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextLabel(text = text) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val iconRotation by animateFloatAsState( + targetValue = if (showDropdownMenu) -180f else 0f, + label = "icon_rotation_animation" + ) + + Text(text = selectedEntry.toString()) + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.rotate(iconRotation), + ) + } + } + + DropdownMenu( + expanded = showDropdownMenu, + onDismissRequest = { showDropdownMenu = false }, + offset = dropdownOffset, + ) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(text = entry.toString()) }, + onClick = { + onEntrySelected(entry) + showDropdownMenu = false + }, + leadingIcon = { + AnimatedVisibility(entry == selectedEntry) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AppSettingsPreview() { + val appSettingsRepository = AppSettingsRepository(LocalContext.current) + PillarboxTheme { + AppSettingsView(AppSettingsViewModel(appSettingsRepository)) + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt index 191dc3ff2..82c3eb0f0 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/playlists/CustomPlaybackSettingsShowcase.kt @@ -5,7 +5,12 @@ package ch.srgssr.pillarbox.demo.ui.showcases.playlists import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -29,10 +35,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.media3.common.Player import ch.srgssr.pillarbox.demo.R @@ -69,11 +75,9 @@ fun CustomPlaybackSettingsShowcase( var pauseAtEndOfItem by remember { mutableStateOf(player.pauseAtEndOfMediaItems) } - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small), - ) { + Column(modifier = modifier) { Box { + var menuOffset by remember { mutableStateOf(DpOffset.Zero) } var showRepeatModeMenu by remember { mutableStateOf(false) } var selectedRepeatModeIndex by remember { mutableIntStateOf( @@ -83,14 +87,38 @@ fun CustomPlaybackSettingsShowcase( ) } + val interactionSource = remember { MutableInteractionSource() } + Row( modifier = Modifier .fillMaxWidth() - .clickable { showRepeatModeMenu = true } - .padding( - horizontal = MaterialTheme.paddings.baseline, - vertical = MaterialTheme.paddings.small, - ), + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressInteraction = PressInteraction.Press(it) + + interactionSource.emit(pressInteraction) + + menuOffset = DpOffset( + x = it.x.toDp(), + y = (it.y - size.height).toDp(), + ) + showRepeatModeMenu = true + + if (tryAwaitRelease()) { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } else { + interactionSource.emit(PressInteraction.Cancel(pressInteraction)) + } + } + ) + } + .indication( + interactionSource = interactionSource, + indication = LocalIndication.current, + ) + .minimumInteractiveComponentSize() + .padding(horizontal = MaterialTheme.paddings.baseline), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -102,10 +130,7 @@ fun CustomPlaybackSettingsShowcase( DropdownMenu( expanded = showRepeatModeMenu, onDismissRequest = { showRepeatModeMenu = false }, - offset = DpOffset( - x = -MaterialTheme.paddings.small, - y = 0.dp, - ), + offset = menuOffset, ) { repeatModes.forEachIndexed { index, (repeatMode, repeatModeLabel) -> DropdownMenuItem( diff --git a/pillarbox-demo/src/main/res/values/strings.xml b/pillarbox-demo/src/main/res/values/strings.xml index d65550668..839c6ee19 100644 --- a/pillarbox-demo/src/main/res/values/strings.xml +++ b/pillarbox-demo/src/main/res/values/strings.xml @@ -35,4 +35,10 @@ all Pause at end of media items Chapters + Library version + Choose text color + Choose text size + Metrics Overlay + Display an overlay on top of the video surface to show useful information. + Enable metrics overlay diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt index ae6c4a2c3..5781451f5 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt @@ -94,7 +94,7 @@ internal class SessionMetrics internal constructor( fun getTotalBitrate(): Long { val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE - var bitrate = 0L + var bitrate = Format.NO_VALUE.toLong() if (videoBitrate > 0) bitrate += videoBitrate if (audioBitrate > 0) bitrate += audioBitrate return bitrate diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index e08c08cda..533aed35f 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -15,6 +15,7 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat import kotlin.coroutines.CoroutineContext @@ -115,8 +116,8 @@ internal class QoSCoordinator( } private fun PlaybackMetrics.toQoSEvent(): QoSEvent { - val bitrateBytes = indicatedBitrate / Byte.SIZE_BYTES - val bandwidthBytes = bandwidth / Byte.SIZE_BYTES + val bitrateBytes = indicatedBitrate.toByteRate() + val bandwidthBytes = bandwidth.toByteRate() return QoSEvent( bandwidth = bandwidthBytes, bitrate = bitrateBytes.toInt(), diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt new file mode 100644 index 000000000..90e3ae449 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/BitrateUtil.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.utils + +/** + * Bitrate util + */ +object BitrateUtil { + + /** + * @return Convert Int in bits rate to Int in byte rate. + */ + fun Int.toByteRate(): Int { + return this / Byte.SIZE_BITS + } + + /** + * @return Convert Long in bits rate to Long in byte rate. + */ + fun Long.toByteRate(): Long { + return this / Byte.SIZE_BITS + } +} From 4c8be12ebf60d8afac2d7107a20c9b146a758268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Tue, 30 Jul 2024 10:00:42 +0200 Subject: [PATCH 14/17] Update heartbeat behavior (#656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joaquim Stähli --- .../commandersact/CommandersActStreaming.kt | 13 +- .../CommandersActTrackerIntegrationTest.kt | 12 +- .../pillarbox/player/PillarboxExoPlayer.kt | 19 +- .../player/qos/PillarboxEventsDispatcher.kt | 6 - .../pillarbox/player/qos/QoSCoordinator.kt | 19 +- .../player/qos/QoSEventsDispatcher.kt | 11 - .../pillarbox/player/utils/Heartbeat.kt | 15 +- .../player/qos/QoSEventsDispatcherTest.kt | 204 ------------------ .../pillarbox/player/utils/HeartbeatTest.kt | 49 ++--- 9 files changed, 61 insertions(+), 287 deletions(-) delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt index 4ab786c0e..42cf627b0 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt @@ -15,6 +15,7 @@ import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.extension.hasAccessibilityRoles import ch.srgssr.pillarbox.player.extension.isForced +import ch.srgssr.pillarbox.player.runOnApplicationLooper import ch.srgssr.pillarbox.player.tracks.audioTracks import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat @@ -41,8 +42,10 @@ internal class CommandersActStreaming( period = POS_PERIOD, coroutineContext = coroutineContext, task = { - if (player.playWhenReady) { - notifyPos(player.currentPosition.milliseconds) + player.runOnApplicationLooper { + if (player.playWhenReady) { + notifyPos(player.currentPosition.milliseconds) + } } }, ) @@ -52,8 +55,10 @@ internal class CommandersActStreaming( period = UPTIME_PERIOD, coroutineContext = coroutineContext, task = { - if (player.playWhenReady && player.isCurrentMediaItemLive) { - notifyUptime(player.currentPosition.milliseconds) + player.runOnApplicationLooper { + if (player.playWhenReady && player.isCurrentMediaItemLive) { + notifyUptime(player.currentPosition.milliseconds) + } } }, ) diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index 1ce392351..e54e8e2da 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -36,16 +36,14 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import io.mockk.verifyOrder -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext import kotlin.math.abs import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -72,8 +70,6 @@ class CommandersActTrackerIntegrationTest { commandersAct = mockk(relaxed = true) testDispatcher = UnconfinedTestDispatcher() - Dispatchers.setMain(testDispatcher) - val context = ApplicationProvider.getApplicationContext() val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository( trackerRepository = MediaItemTrackerRepository(), @@ -85,23 +81,21 @@ class CommandersActTrackerIntegrationTest { } val mediaCompositionWithFallbackService = LocalMediaCompositionWithFallbackService(context) - player = DefaultPillarbox( context = context, mediaItemTrackerRepository = mediaItemTrackerRepository, mediaCompositionService = mediaCompositionWithFallbackService, clock = clock, - coroutineContext = testDispatcher, + // Use other CoroutineContext to avoid infinite loop because Heartbeat is also running in Pillarbox. + coroutineContext = EmptyCoroutineContext, ) } @AfterTest - @OptIn(ExperimentalCoroutinesApi::class) fun tearDown() { clearAllMocks() player.release() shadowOf(Looper.getMainLooper()).idle() - Dispatchers.resetMain() } @Test diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index d5fe10ad1..7c8762f99 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player import android.content.Context +import android.os.Handler import androidx.annotation.VisibleForTesting import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -40,6 +41,8 @@ import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import ch.srgssr.pillarbox.player.tracker.TimeRangeTracker import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking import kotlin.coroutines.CoroutineContext /** @@ -133,7 +136,6 @@ class PillarboxExoPlayer internal constructor( sessionManager = sessionManager, coroutineContext = coroutineContext, ) - addListener(analyticsCollector) exoPlayer.addListener(ComponentListener()) itemPillarboxDataTracker.addCallback(timeRangeTracker) @@ -492,3 +494,18 @@ internal fun Window.isAtDefaultPosition(positionMs: Long): Boolean { private const val NormalSpeed = 1.0f private fun MediaItem.clearTag() = this.buildUpon().setTag(null).build() + +/** + * Run task in the same thread as [Player.getApplicationLooper] if it is needed. + * + * @param task The task to run. + */ +fun Player.runOnApplicationLooper(task: () -> Unit) { + if (applicationLooper.thread != Thread.currentThread()) { + runBlocking(Handler(applicationLooper).asCoroutineDispatcher("exoplayer")) { + task() + } + } else { + task() + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt index 24c48eac0..b55185ce8 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt @@ -101,12 +101,6 @@ class PillarboxEventsDispatcher( DebugLogger.debug(TAG, "onPlayerReleased") notifyListeners { onPlayerReleased() } } - - override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { - val session = sessionManager.getSessionFromEventTime(eventTime) ?: return - - notifyListeners { onIsPlaying(session, isPlaying) } - } } private companion object { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 533aed35f..ebbeb1ae1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -15,6 +15,7 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.runOnApplicationLooper import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat @@ -35,8 +36,9 @@ internal class QoSCoordinator( coroutineContext = coroutineContext, task = { val session = currentSession ?: return@Heartbeat - - sendEvent("HEARTBEAT", session) + player.runOnApplicationLooper { + sendEvent("HEARTBEAT", session) + } }, ) @@ -143,17 +145,6 @@ internal class QoSCoordinator( override fun onMediaStart(session: PlaybackSessionManager.Session) { } - override fun onIsPlaying( - session: PlaybackSessionManager.Session, - isPlaying: Boolean, - ) { - if (isPlaying) { - heartbeat.start(restart = false) - } else { - heartbeat.stop() - } - } - override fun onSeek(session: PlaybackSessionManager.Session) { sendEvent("SEEK", session) } @@ -204,7 +195,7 @@ internal class QoSCoordinator( } private companion object { - private val HEARTBEAT_PERIOD = 10.seconds + private val HEARTBEAT_PERIOD = 30.seconds private const val TAG = "QoSCoordinator" } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt index d1ddb0a3c..5181df49b 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcher.kt @@ -22,17 +22,6 @@ interface QoSEventsDispatcher { */ fun onMediaStart(session: PlaybackSessionManager.Session) = Unit - /** - * On is playing - * - * @param session - * @param isPlaying - */ - fun onIsPlaying( - session: PlaybackSessionManager.Session, - isPlaying: Boolean, - ) = Unit - /** * On seek * diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt index 771736c00..a8bca4c15 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/utils/Heartbeat.kt @@ -4,15 +4,12 @@ */ package ch.srgssr.pillarbox.player.utils -import androidx.annotation.MainThread import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlin.coroutines.CoroutineContext import kotlin.time.Duration @@ -22,15 +19,15 @@ import kotlin.time.Duration * @param startDelay The initial delay before the first execution of [task]. * @param period The period between two executions of [task]. * @param coroutineContext The coroutine context in which [Heartbeat] is run. - * @param task The task to execute, on the main [Thread] at regular [intervals][period]. + * @param task The task to execute at regular [intervals][period]. */ class Heartbeat( private val startDelay: Duration = Duration.ZERO, private val period: Duration, private val coroutineContext: CoroutineContext, - @MainThread private val task: () -> Unit, + private val task: () -> Unit, ) { - private val coroutineScope = CoroutineScope(coroutineContext + CoroutineName("pillarbox-heart-beat")) + private val coroutineScope = CoroutineScope(coroutineContext + CoroutineName("pillarbox-heartbeat")) private var job: Job? = null @@ -50,12 +47,8 @@ class Heartbeat( job = coroutineScope.launch { delay(startDelay) - while (isActive) { - runBlocking(Dispatchers.Main) { - task() - } - + task() delay(period) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt deleted file mode 100644 index da1ce7788..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import android.content.Context -import android.os.Looper -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -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.analytics.PlaybackSessionManager -import io.mockk.clearAllMocks -import io.mockk.clearMocks -import io.mockk.confirmVerified -import io.mockk.mockk -import io.mockk.verify -import org.junit.runner.RunWith -import org.robolectric.Shadows.shadowOf -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@RunWith(AndroidJUnit4::class) -class QoSEventsDispatcherTest { - private lateinit var clock: FakeClock - private lateinit var player: ExoPlayer - private lateinit var eventsDispatcherListener: QoSEventsDispatcher.Listener - - @BeforeTest - fun setUp() { - val context = ApplicationProvider.getApplicationContext() - - clock = FakeClock(true) - eventsDispatcherListener = mockk(relaxed = true) - player = ExoPlayer.Builder(context) - .setClock(clock) - .build() - .apply { - prepare() - } - - val sessionManager = PlaybackSessionManager().apply { - setPlayer(player) - } - - PillarboxEventsDispatcher(sessionManager).apply { - registerPlayer(player) - addListener(eventsDispatcherListener) - } - - clearMocks(eventsDispatcherListener) - } - - @AfterTest - fun tearDown() { - clearAllMocks() - player.release() - shadowOf(Looper.getMainLooper()).idle() - } - - @Test - fun `play single media item`() { - val mediaItem = MediaItem.fromUri(VOD1) - - player.setMediaItem(mediaItem) - player.play() - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - val onIsPlayingSessions = mutableListOf() - val onIsPlayingValue = mutableListOf() - - verify { - eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(2, onIsPlayingValue.size) - assertEquals(1, onIsPlayingSessions.distinctBy { it.sessionId }.size) - assertEquals(listOf(true, false), onIsPlayingValue) - } - - @Test - fun `play multiple media items`() { - val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } - - player.setMediaItems(mediaItems) - player.play() - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - // To ensure that the final `onSessionFinished` is triggered. - player.clearMediaItems() - - TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - - val onIsPlayingSessions = mutableListOf() - val onIsPlayingValue = mutableListOf() - - verify { - eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(6, onIsPlayingValue.size) - assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) - assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) - } - - @Test - fun `play multiple media items, remove upcoming media item`() { - val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } - - player.setMediaItems(mediaItems) - player.play() - player.removeMediaItem(player.currentMediaItemIndex + 1) - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - // To ensure that the final `onSessionFinished` is triggered. - player.clearMediaItems() - - TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - - val onIsPlayingSessions = mutableListOf() - val onIsPlayingValue = mutableListOf() - - verify { - eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(4, onIsPlayingValue.size) - assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) - assertEquals(listOf(true, false, true, false), onIsPlayingValue) - } - - @Test - fun `play multiple media items, remove current media item`() { - val mediaItems = listOf(VOD1, VOD2, VOD3).map { MediaItem.fromUri(it) } - - player.setMediaItems(mediaItems) - player.play() - player.removeMediaItem(player.currentMediaItemIndex) - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - // To ensure that the final `onSessionFinished` is triggered. - player.clearMediaItems() - - TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - - val onIsPlayingSessions = mutableListOf() - val onIsPlayingValue = mutableListOf() - - verify { - eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(4, onIsPlayingValue.size) - assertEquals(2, onIsPlayingSessions.distinctBy { it.sessionId }.size) - assertEquals(listOf(true, false, true, false), onIsPlayingValue) - } - - @Test - fun `play multiple same media items create multiple sessions`() { - val mediaItems = listOf(VOD1, VOD1, VOD3).map { MediaItem.fromUri(it) } - - player.setMediaItems(mediaItems) - player.play() - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - - // To ensure that the final `onSessionFinished` is triggered. - player.clearMediaItems() - - TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - - val onIsPlayingSessions = mutableListOf() - val onIsPlayingValue = mutableListOf() - - verify { - eventsDispatcherListener.onIsPlaying(capture(onIsPlayingSessions), capture(onIsPlayingValue)) - } - confirmVerified(eventsDispatcherListener) - - assertEquals(6, onIsPlayingValue.size) - assertEquals(3, onIsPlayingSessions.distinctBy { it.sessionId }.size) - assertEquals(listOf(true, false, true, false, true, false), onIsPlayingValue) - } - - private companion object { - private const val VOD1 = "https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8" - private const val VOD2 = "https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8" - private const val VOD3 = "https://rts-vod-amd.akamaized.net/ww/13444466/2787e520-412f-35fb-83d7-8dbb31b5c684/master.m3u8" - } -} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt index 7ed3d49e7..e47cc91bb 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/utils/HeartbeatTest.kt @@ -9,14 +9,11 @@ import android.os.Looper import androidx.media3.exoplayer.ExoPlayer import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf import kotlin.test.AfterTest @@ -41,16 +38,12 @@ class HeartbeatTest { @BeforeTest fun setUp() { - Dispatchers.setMain(testDispatcher) - taskRunsCount = 0 } @AfterTest fun tearDown() { shadowOf(Looper.getMainLooper()).idle() - - Dispatchers.resetMain() } @Test @@ -314,27 +307,29 @@ class HeartbeatTest { } @Test - fun `verify player is accessible from the task`() = runTest(testDispatcher) { + fun `verify player is accessible from the task`() { val context = ApplicationProvider.getApplicationContext() val player = ExoPlayer.Builder(context).build() - var taskCalled = false - - val heartbeat = Heartbeat( - period = 10.seconds, - coroutineContext = coroutineContext, - task = { - player.currentPosition - taskCalled = true - }, - ) - - heartbeat.start() - advanceTimeBy(15.seconds) - heartbeat.stop() - advanceTimeBy(15.seconds) - - assertTrue(taskCalled) - - player.release() + runTest(testDispatcher) { + var taskCalled = false + + val heartbeat = Heartbeat( + period = 10.seconds, + coroutineContext = coroutineContext, + task = { + player.currentPosition + taskCalled = true + }, + ) + + heartbeat.start() + advanceTimeBy(15.seconds) + heartbeat.stop() + advanceTimeBy(15.seconds) + + assertTrue(taskCalled) + + player.release() + } } } From 2ebab7a272759d2d93073f3e582677a251f4e9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 25 Jul 2024 09:20:02 +0200 Subject: [PATCH 15/17] Move QoS models into a dedicated `models` package --- .../java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt | 5 +++++ .../java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt | 1 + .../ch/srgssr/pillarbox/player/qos/{ => models}/QoSError.kt | 2 +- .../ch/srgssr/pillarbox/player/qos/{ => models}/QoSEvent.kt | 2 +- .../srgssr/pillarbox/player/qos/{ => models}/QoSMessage.kt | 2 +- .../srgssr/pillarbox/player/qos/{ => models}/QoSSession.kt | 2 +- .../pillarbox/player/qos/{ => models}/QoSSessionTimings.kt | 2 +- .../srgssr/pillarbox/player/qos/{ => models}/QoSErrorTest.kt | 2 +- .../pillarbox/player/qos/{ => models}/QoSSessionTest.kt | 2 +- .../player/qos/{ => models}/QoSSessionTimingsTest.kt | 2 +- 10 files changed, 14 insertions(+), 8 deletions(-) rename pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSError.kt (96%) rename pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSEvent.kt (95%) rename pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSMessage.kt (91%) rename pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSSession.kt (98%) rename pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSSessionTimings.kt (95%) rename pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSErrorTest.kt (97%) rename pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSSessionTest.kt (99%) rename pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/{ => models}/QoSSessionTimingsTest.kt (90%) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index ebbeb1ae1..2c49ffe39 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -15,6 +15,11 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.qos.models.QoSError +import ch.srgssr.pillarbox.player.qos.models.QoSEvent +import ch.srgssr.pillarbox.player.qos.models.QoSMessage +import ch.srgssr.pillarbox.player.qos.models.QoSSession +import ch.srgssr.pillarbox.player.qos.models.QoSSessionTimings import ch.srgssr.pillarbox.player.runOnApplicationLooper import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate import ch.srgssr.pillarbox.player.utils.DebugLogger diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt index 5e4860225..0d282775d 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessageHandler.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.player.qos import android.util.Log +import ch.srgssr.pillarbox.player.qos.models.QoSMessage /** * QoS message handler diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt similarity index 96% rename from pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt index 1030b4e27..d353246e9 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models /** * Represents a [Player][androidx.media3.common.Player] error to send to a QoS server. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt similarity index 95% rename from pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt index 628bf9d38..82ee72638 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models /** * Represents a generic event, which contains metrics about the current media stream. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMessage.kt similarity index 91% rename from pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMessage.kt index 92dd12605..666b2d03f 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMessage.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models /** * Represents a QoS message. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt similarity index 98% rename from pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt index 1405c01e6..ad6d7b34a 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models import android.content.Context import android.content.res.Configuration diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt similarity index 95% rename from pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt index 2128a86e8..f32962160 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models import kotlin.time.Duration diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt similarity index 97% rename from pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt index f99a7aaed..b94d6f3f2 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSErrorTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models import java.lang.RuntimeException import kotlin.test.Test diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt similarity index 99% rename from pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt index 966ec0db6..4a8e33d9f 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models import android.content.Context import androidx.test.core.app.ApplicationProvider diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt similarity index 90% rename from pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt index 37e499a39..13436f531 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.player.qos +package ch.srgssr.pillarbox.player.qos.models import kotlin.test.Test import kotlin.test.assertNull From aa4a8f95304be98e5673ccf2e19600625916f65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 25 Jul 2024 11:37:35 +0200 Subject: [PATCH 16/17] Update QoS models --- .../pillarbox/player/qos/QoSCoordinator.kt | 31 ++- .../pillarbox/player/qos/models/QoSDevice.kt | 30 ++ .../pillarbox/player/qos/models/QoSError.kt | 4 + .../pillarbox/player/qos/models/QoSEvent.kt | 6 +- .../pillarbox/player/qos/models/QoSMedia.kt | 20 ++ .../pillarbox/player/qos/models/QoSOS.kt | 16 ++ .../pillarbox/player/qos/models/QoSPlayer.kt | 18 ++ .../pillarbox/player/qos/models/QoSScreen.kt | 16 ++ .../pillarbox/player/qos/models/QoSSession.kt | 87 +++--- .../player/qos/models/QoSSessionTimings.kt | 17 +- .../pillarbox/player/qos/models/QoSStall.kt | 16 ++ .../player/qos/models/QoSErrorTest.kt | 2 + .../player/qos/models/QoSSessionTest.kt | 261 +++++++++++------- .../qos/models/QoSSessionTimingsTest.kt | 7 +- 14 files changed, 357 insertions(+), 174 deletions(-) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSDevice.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMedia.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSOS.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSPlayer.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSScreen.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSStall.kt diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 2c49ffe39..975b73ebc 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -17,9 +17,11 @@ import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics import ch.srgssr.pillarbox.player.qos.models.QoSError import ch.srgssr.pillarbox.player.qos.models.QoSEvent +import ch.srgssr.pillarbox.player.qos.models.QoSMedia import ch.srgssr.pillarbox.player.qos.models.QoSMessage import ch.srgssr.pillarbox.player.qos.models.QoSSession import ch.srgssr.pillarbox.player.qos.models.QoSSessionTimings +import ch.srgssr.pillarbox.player.qos.models.QoSStall import ch.srgssr.pillarbox.player.runOnApplicationLooper import ch.srgssr.pillarbox.player.utils.BitrateUtil.toByteRate import ch.srgssr.pillarbox.player.utils.DebugLogger @@ -68,11 +70,11 @@ internal class QoSCoordinator( sessionManager.getSessionById(metrics.sessionId)?.let { sendStartEvent( session = it, - timings = QoSSessionTimings( - asset = metrics.loadDuration.asset, - mediaSource = metrics.loadDuration.source, - currentToStart = metrics.loadDuration.timeToReady, - drm = metrics.loadDuration.drm + timeMetrics = QoSSessionTimings( + asset = metrics.loadDuration.source, + drm = metrics.loadDuration.drm, + metadata = metrics.loadDuration.asset, + total = metrics.loadDuration.timeToReady, ) ) } @@ -131,8 +133,10 @@ internal class QoSCoordinator( bufferDuration = player.totalBufferedDuration, playbackDuration = playbackDuration.inWholeMilliseconds, playerPosition = player.currentPosition, - stallCount = stallCount, - stallDuration = stallDuration.inWholeSeconds, + stall = QoSStall( + count = stallCount, + duration = stallDuration.inWholeMilliseconds, + ), url = url.toString(), ) } @@ -171,6 +175,7 @@ internal class QoSCoordinator( throwable = it, playerPosition = player.currentPosition, severity = QoSError.Severity.FATAL, + url = url, ), ) } @@ -185,16 +190,20 @@ internal class QoSCoordinator( private fun sendStartEvent( session: PlaybackSessionManager.Session, - timings: QoSSessionTimings, + timeMetrics: QoSSessionTimings, ) { sendEvent( eventName = "START", session = session, data = QoSSession( context = context, - mediaId = session.mediaItem.mediaId, - mediaSource = session.mediaItem.localConfiguration?.uri.toString(), - timings = timings, + media = QoSMedia( + assetUrl = url, + id = session.mediaItem.mediaId, + metadataUrl = session.mediaItem.localConfiguration?.uri.toString(), + origin = context.packageName, + ), + timeMetrics = timeMetrics, ), ) } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSDevice.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSDevice.kt new file mode 100644 index 000000000..92a9524a0 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSDevice.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about the device. + * + * @property id The unique identifier of the device. + * @property model The model of the device. + * @property type The type of device. + */ +data class QoSDevice( + val id: String, + val model: String, + val type: DeviceType, +) { + /** + * The type of device. + */ + enum class DeviceType { + CAR, + DESKTOP, + PHONE, + TABLET, + TV, + UNKNOWN, + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt index d353246e9..64bbc9b24 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSError.kt @@ -12,6 +12,7 @@ package ch.srgssr.pillarbox.player.qos.models * @property name The name of the error. * @property playerPosition The position of the player when the error occurred, in milliseconds, or `null` if not available. * @property severity The severity of the error, either [FATAL][Severity.FATAL] or [WARNING][Severity.WARNING]. + * @property url The last loaded url. */ data class QoSError( val log: String, @@ -19,6 +20,7 @@ data class QoSError( val name: String, val playerPosition: Long?, val severity: Severity, + val url: String, ) { /** * Represents a [Player][androidx.media3.common.Player] error severity. @@ -32,11 +34,13 @@ data class QoSError( throwable: Throwable, playerPosition: Long?, severity: Severity, + url: String, ) : this( log = throwable.stackTraceToString(), message = throwable.message.orEmpty(), name = throwable::class.simpleName.orEmpty(), playerPosition = playerPosition, severity = severity, + url = url, ) } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt index 82ee72638..2fc58aef3 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSEvent.kt @@ -12,8 +12,7 @@ package ch.srgssr.pillarbox.player.qos.models * @property bufferDuration The forward duration of the buffer, in milliseconds. * @property playbackDuration The duration of the playback, in milliseconds. * @property playerPosition The position of the player, in milliseconds. - * @property stallCount The number of stalls that have occurred, not as a result of a seek. - * @property stallDuration The total duration of the stalls, in milliseconds. + * @property stall The information about stalls. * @property url The URL of the stream. */ data class QoSEvent( @@ -22,7 +21,6 @@ data class QoSEvent( val bufferDuration: Long, val playbackDuration: Long, val playerPosition: Long, - val stallCount: Int, - val stallDuration: Long, + val stall: QoSStall, val url: String, ) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMedia.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMedia.kt new file mode 100644 index 000000000..d399839ae --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSMedia.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about the media being played. + * + * @property assetUrl The URL of the asset. + * @property id The id of the media. + * @property metadataUrl The URL of the metadata. + * @property origin The origin of the media. + */ +data class QoSMedia( + val assetUrl: String, + val id: String, + val metadataUrl: String, + val origin: String, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSOS.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSOS.kt new file mode 100644 index 000000000..0347f30eb --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSOS.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about the operating system. + * + * @property name The name of the operating system. + * @property version The version of the operating system. + */ +data class QoSOS( + val name: String, + val version: String, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSPlayer.kt new file mode 100644 index 000000000..8a174ed43 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSPlayer.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about the player. + * + * @property name The name of the player. + * @property platform The platform of the player. + * @property version The version of the player. + */ +data class QoSPlayer( + val name: String, + val platform: String, + val version: String, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSScreen.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSScreen.kt new file mode 100644 index 000000000..be912a787 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSScreen.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about the device screen. + * + * @property height The height of the screen, in pixels. + * @property width The width of the screen, in pixels. + */ +data class QoSScreen( + val height: Int, + val width: Int, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt index ad6d7b34a..b9734a23c 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSession.kt @@ -10,65 +10,51 @@ import android.graphics.Rect import android.os.Build import android.view.WindowManager import ch.srgssr.pillarbox.player.BuildConfig +import ch.srgssr.pillarbox.player.qos.models.QoSDevice.DeviceType /** * Represents a QoS session, which contains information about the device, current media, and player. * - * @property deviceId The unique identifier of the device. - * @property deviceModel The model of the device. - * @property deviceType The type of device. - * @property mediaId The identifier of the media being played. - * @property mediaSource The source URL of the media being played. - * @property operatingSystemName The name of the operating system. - * @property operatingSystemVersion The version of the operating system. - * @property origin The origin of the player. - * @property playerName The name of the player. - * @property playerPlatform The platform of the player. - * @property playerVersion The version of the player. - * @property screenHeight The height of the screen in pixels. - * @property screenWidth The width of the screen in pixels. - * @property timings The timing until the current media started to play. + * @property device The information about the device. + * @property media The information about the media being played. + * @property operatingSystem The information about the operating system. + * @property player The information about the player. + * @property screen The information about the device screen. + * @property timeMetrics The metrics about the time needed to load the various media components. */ data class QoSSession( - val deviceId: String, - val deviceModel: String = getDeviceModel(), - val deviceType: DeviceType, - val mediaId: String, - val mediaSource: String, - val operatingSystemName: String = PLATFORM_NAME, - val operatingSystemVersion: String = OPERATING_SYSTEM_VERSION, - val origin: String, - val playerName: String = PLAYER_NAME, - val playerPlatform: String = PLATFORM_NAME, - val playerVersion: String = PLAYER_VERSION, - val screenHeight: Int, - val screenWidth: Int, - val timings: QoSSessionTimings = QoSSessionTimings.Empty, + val device: QoSDevice, + val media: QoSMedia, + val operatingSystem: QoSOS = QoSOS( + name = PLATFORM_NAME, + version = OPERATING_SYSTEM_VERSION, + ), + val player: QoSPlayer = QoSPlayer( + name = PLAYER_NAME, + platform = PLATFORM_NAME, + version = PLAYER_VERSION, + ), + val screen: QoSScreen, + val timeMetrics: QoSSessionTimings = QoSSessionTimings.Empty, ) { - /** - * The type of device. - */ - enum class DeviceType { - CAR, - PHONE, - TABLET, - TV, - } - constructor( context: Context, - mediaId: String, - mediaSource: String, - timings: QoSSessionTimings + media: QoSMedia, + timeMetrics: QoSSessionTimings, ) : this( - deviceId = getDeviceId(), - deviceType = context.getDeviceType(), - mediaId = mediaId, - mediaSource = mediaSource, - origin = context.packageName, - screenHeight = context.getWindowBounds().height(), - screenWidth = context.getWindowBounds().width(), - timings = timings + device = QoSDevice( + id = getDeviceId(), + model = getDeviceModel(), + type = context.getDeviceType(), + ), + media = media, + screen = context.getWindowBounds().let { windowBounds -> + QoSScreen( + height = windowBounds.height(), + width = windowBounds.width(), + ) + }, + timeMetrics = timeMetrics, ) private companion object { @@ -92,6 +78,7 @@ data class QoSSession( val configuration = resources.configuration return when (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) { Configuration.UI_MODE_TYPE_CAR -> DeviceType.CAR + Configuration.UI_MODE_TYPE_DESK -> DeviceType.DESKTOP Configuration.UI_MODE_TYPE_NORMAL -> { val smallestWidthDp = configuration.smallestScreenWidthDp @@ -103,7 +90,7 @@ data class QoSSession( } Configuration.UI_MODE_TYPE_TELEVISION -> DeviceType.TV - else -> DeviceType.PHONE // TODO Do we assume PHONE by default? Or do we throw an exception? + else -> DeviceType.UNKNOWN } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt index f32962160..994171547 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt @@ -10,16 +10,18 @@ import kotlin.time.Duration * Represents the timings until the current media started to play. * * @property asset The time spent to load the asset. - * @property currentToStart The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until it - * started to play. * @property drm The time spent to load the DRM. - * @property mediaSource The time spent to load the media source. + * @property metadata The time spent to load the media source. + * @property token The time spent to load the token. + * @property total The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until it + * started to play. */ data class QoSSessionTimings( val asset: Duration? = null, - val currentToStart: Duration? = null, val drm: Duration? = null, - val mediaSource: Duration? = null, + val metadata: Duration? = null, + val token: Duration? = null, + val total: Duration? = null, ) { companion object { /** @@ -27,9 +29,10 @@ data class QoSSessionTimings( */ val Empty = QoSSessionTimings( asset = null, - currentToStart = null, drm = null, - mediaSource = null, + metadata = null, + token = null, + total = null, ) } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSStall.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSStall.kt new file mode 100644 index 000000000..943e5c52b --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSStall.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.qos.models + +/** + * Information about stalls. + * + * @property count The number of stalls that have occurred, not as a result of a seek. + * @property duration The total duration of the stalls, in milliseconds. + */ +data class QoSStall( + val count: Int, + val duration: Long, +) diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt index b94d6f3f2..3871b05b9 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSErrorTest.kt @@ -19,6 +19,7 @@ class QoSErrorTest { throwable = throwable, playerPosition = 5.minutes.inWholeMilliseconds, severity = QoSError.Severity.WARNING, + url = "", ) val logLines = qosError.log.lineSequence() @@ -39,6 +40,7 @@ class QoSErrorTest { throwable = throwable, playerPosition = 30.seconds.inWholeMilliseconds, severity = QoSError.Severity.FATAL, + url = "", ) val logLines = qosError.log.lineSequence() diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt index 4a8e33d9f..4896ff0b6 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTest.kt @@ -18,18 +18,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.PHONE, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -37,18 +40,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values (API 30)`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("robolectric robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("11", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("robolectric robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.PHONE, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("11", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -56,18 +62,43 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for car`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.CAR, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.CAR, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) + } + + @Test + @Config(qualifiers = "desk") + fun `contextConstructor provides correct default values for desktop`() { + val qosSession = createQoSSession() + + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.DESKTOP, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -75,18 +106,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for phone (sw320dp)`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.PHONE, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -94,18 +128,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for tablet (sw600dp)`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.TABLET, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.TABLET, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -113,18 +150,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for tablet (sw720dp)`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.TABLET, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.TABLET, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -132,18 +172,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for TV`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.TV, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.TV, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } @Test @@ -151,18 +194,21 @@ class QoSSessionTest { fun `contextConstructor provides correct default values for watch`() { val qosSession = createQoSSession() - assertEquals("", qosSession.deviceId) - assertEquals("unknown robolectric", qosSession.deviceModel) - assertEquals(QoSSession.DeviceType.PHONE, qosSession.deviceType) - assertEquals("android", qosSession.operatingSystemName) - assertEquals("5.0.2", qosSession.operatingSystemVersion) - assertEquals("ch.srgssr.pillarbox.player.test", qosSession.origin) - assertEquals("pillarbox", qosSession.playerName) - assertEquals("android", qosSession.playerPlatform) - assertEquals("Local", qosSession.playerVersion) - assertEquals(470, qosSession.screenHeight) - assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Empty, qosSession.timings) + assertEquals("", qosSession.device.id) + assertEquals("unknown robolectric", qosSession.device.model) + assertEquals(QoSDevice.DeviceType.UNKNOWN, qosSession.device.type) + assertEquals(ASSET_URL, qosSession.media.assetUrl) + assertEquals(MEDIA_ID, qosSession.media.id) + assertEquals(METADATA_URL, qosSession.media.metadataUrl) + assertEquals(ORIGIN, qosSession.media.origin) + assertEquals(OPERATING_SYSTEM_NAME, qosSession.operatingSystem.name) + assertEquals("5.0.2", qosSession.operatingSystem.version) + assertEquals(PLAYER_NAME, qosSession.player.name) + assertEquals(PLAYER_PLATFORM, qosSession.player.platform) + assertEquals(PLAYER_VERSION, qosSession.player.version) + assertEquals(SCREEN_HEIGHT, qosSession.screen.height) + assertEquals(SCREEN_WIDTH, qosSession.screen.width) + assertEquals(QoSSessionTimings.Empty, qosSession.timeMetrics) } private fun createQoSSession(): QoSSession { @@ -170,9 +216,26 @@ class QoSSessionTest { return QoSSession( context = context, - mediaId = "urn:rts:video:12345", - mediaSource = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY", - timings = QoSSessionTimings.Empty, + media = QoSMedia( + assetUrl = ASSET_URL, + id = MEDIA_ID, + metadataUrl = METADATA_URL, + origin = ORIGIN, + ), + timeMetrics = QoSSessionTimings.Empty, ) } + + private companion object { + private const val ASSET_URL = "https://rts-vod-amd.akamaized.net/ww/12345/3037738d-fe91-32e3-93f2-4dbb62a0f9bd/master.m3u8" + private const val MEDIA_ID = "urn:rts:video:12345" + private const val METADATA_URL = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY" + private const val OPERATING_SYSTEM_NAME = "android" + private const val ORIGIN = "ch.srgssr.pillarbox.player.test" + private const val PLAYER_NAME = "pillarbox" + private const val PLAYER_PLATFORM = "android" + private const val PLAYER_VERSION = "Local" + private const val SCREEN_HEIGHT = 470 + private const val SCREEN_WIDTH = 320 + } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt index 13436f531..9fdb6ce4e 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt @@ -9,12 +9,13 @@ import kotlin.test.assertNull class QoSSessionTimingsTest { @Test - fun `zero timings`() { + fun `empty timings`() { val timings = QoSSessionTimings.Empty assertNull(timings.asset) - assertNull(timings.currentToStart) assertNull(timings.drm) - assertNull(timings.mediaSource) + assertNull(timings.metadata) + assertNull(timings.token) + assertNull(timings.total) } } From 11895dca75d993dddd718c9f8411b6d956ff4111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Tue, 30 Jul 2024 10:10:31 +0200 Subject: [PATCH 17/17] Remove `token` timing --- .../ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt | 3 --- .../pillarbox/player/qos/models/QoSSessionTimingsTest.kt | 1 - 2 files changed, 4 deletions(-) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt index 994171547..360bf8132 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimings.kt @@ -12,7 +12,6 @@ import kotlin.time.Duration * @property asset The time spent to load the asset. * @property drm The time spent to load the DRM. * @property metadata The time spent to load the media source. - * @property token The time spent to load the token. * @property total The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until it * started to play. */ @@ -20,7 +19,6 @@ data class QoSSessionTimings( val asset: Duration? = null, val drm: Duration? = null, val metadata: Duration? = null, - val token: Duration? = null, val total: Duration? = null, ) { companion object { @@ -31,7 +29,6 @@ data class QoSSessionTimings( asset = null, drm = null, metadata = null, - token = null, total = null, ) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt index 9fdb6ce4e..0f6ec0487 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/models/QoSSessionTimingsTest.kt @@ -15,7 +15,6 @@ class QoSSessionTimingsTest { assertNull(timings.asset) assertNull(timings.drm) assertNull(timings.metadata) - assertNull(timings.token) assertNull(timings.total) } }