From de5a6976678bee56bb75512e517d7410584842c3 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] 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 + } + } +}