-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add models for QoS and an
AnalyticsListener
to gather session load …
…times (#596) Co-authored-by: Joaquim Stähli <[email protected]>
- Loading branch information
Showing
14 changed files
with
793 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSError.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
) | ||
} |
28 changes: 28 additions & 0 deletions
28
pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSEvent.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
) |
20 changes: 20 additions & 0 deletions
20
pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSMessage.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(), | ||
) |
121 changes: 121 additions & 0 deletions
121
pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} | ||
} |
92 changes: 92 additions & 0 deletions
92
pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionAnalyticsListener.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String>() | ||
private val qosSessions = mutableMapOf<String, QoSSession>() | ||
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(), | ||
) | ||
} | ||
} |
Oops, something went wrong.