Skip to content

Commit

Permalink
Qos (#694)
Browse files Browse the repository at this point in the history
Co-authored-by: Gaëtan Muller <[email protected]>
Co-authored-by: Gaëtan Muller <[email protected]>
  • Loading branch information
3 people authored Sep 12, 2024
1 parent 0c3e690 commit f65758a
Show file tree
Hide file tree
Showing 94 changed files with 7,185 additions and 179 deletions.
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ androidx-annotation = "1.8.2"
androidx-compose = "2024.09.01"
androidx-compose-material-navigation = "1.7.0-beta01" # TODO Remove this once https://issuetracker.google.com/issues/347719428 is resolved
androidx-core = "1.13.1"
androidx-datastore = "1.1.1"
androidx-fragment = "1.8.3"
androidx-lifecycle = "2.8.5"
androidx-media3 = "1.4.1"
Expand Down Expand Up @@ -43,6 +44,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" }
Expand Down Expand Up @@ -120,6 +122,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-unit = { module = "androidx.compose.ui:ui-unit" }
androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" }
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material-navigation = { module = "androidx.compose.material:material-navigation", version.ref = "androidx-compose-material-navigation" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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
import kotlin.time.Duration.Companion.seconds

Expand Down Expand Up @@ -56,6 +58,7 @@ object DefaultPillarbox {
mediaCompositionService = mediaCompositionService,
loadControl = loadControl,
clock = Clock.DEFAULT,
coroutineContext = Dispatchers.Default,
)
}

Expand All @@ -69,6 +72,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
Expand All @@ -80,6 +84,7 @@ object DefaultPillarbox {
loadControl: LoadControl = DefaultLoadControl(),
mediaCompositionService: MediaCompositionService = HttpMediaCompositionService(),
clock: Clock,
coroutineContext: CoroutineContext,
): PillarboxExoPlayer {
return PillarboxExoPlayer(
context = context,
Expand All @@ -91,6 +96,7 @@ object DefaultPillarbox {
mediaItemTrackerProvider = mediaItemTrackerRepository,
loadControl = loadControl,
clock = clock,
coroutineContext = coroutineContext,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ package ch.srgssr.pillarbox.core.business.akamai

import android.net.Uri
import android.net.UrlQuerySanitizer
import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient
import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenProvider.Companion.TOKEN_SERVICE_URL
import ch.srgssr.pillarbox.player.network.PillarboxHttpClient
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
Expand All @@ -16,16 +17,15 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Akamai token provider fetch and rewrite given Uri with a Token received from [tokenService]
*
* Akamai token provider fetch and rewrite given Uri with a Token received from [TOKEN_SERVICE_URL].
*/
class AkamaiTokenProvider(private val httpClient: HttpClient = DefaultHttpClient()) {
class AkamaiTokenProvider(private val httpClient: HttpClient = PillarboxHttpClient()) {

/**
* Request and append a Akamai token to [uri]
* Request and append an Akamai token to [uri]
*
* @param uri protected by a token
* @return tokenized [uri] or [uri] if it fail
* @return tokenized [uri] or [uri] if it fails
*/
suspend fun tokenizeUri(uri: Uri): Uri {
val acl = getAcl(uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,29 @@
*/
package ch.srgssr.pillarbox.core.business.integrationlayer.service

import ch.srgssr.pillarbox.player.network.PillarboxHttpClient
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json
import okhttp3.logging.HttpLoggingInterceptor

/**
* Default ktor HttpClient.
* Default Ktor [HttpClient].
*
* This class is deprecated in favor of [PillarboxHttpClient].
* The latter provides a similar setup as this class, with the following differences:
* - `classDiscriminatorMode` set to [ClassDiscriminatorMode.NONE]: don't include class name in the JSON output for polymorphic classes.
* - `explicitNulls` set to `false`: don't include `null` fields in the JSON output.
* - Logging is set to `BODY` in debug, and `NONE otherwise.
*/
@Deprecated(
message = "Use `PillarboxHttpClient` instead.",
replaceWith = ReplaceWith("PillarboxHttpClient", imports = ["ch.srgssr.pillarbox.player.network.PillarboxHttpClient"]),
)
object DefaultHttpClient {
internal val jsonSerializer = Json {
encodeDefaults = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package ch.srgssr.pillarbox.core.business.integrationlayer.service

import android.net.Uri
import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition
import ch.srgssr.pillarbox.player.network.PillarboxHttpClient
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
Expand All @@ -19,7 +20,7 @@ import java.net.URL
* @param httpClient Ktor HttpClient to make requests.
*/
class HttpMediaCompositionService(
private val httpClient: HttpClient = DefaultHttpClient(),
private val httpClient: HttpClient = PillarboxHttpClient(),
) : MediaCompositionService {

override suspend fun fetchMediaComposition(uri: Uri): Result<MediaComposition> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,20 @@ 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.runOnApplicationLooper
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
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,
Expand All @@ -47,8 +37,33 @@ internal class CommandersActStreaming(
Idle, Playing, Paused, HasSeek
}

private val positionHeartbeat = Heartbeat(
startDelay = HEART_BEAT_DELAY,
period = POS_PERIOD,
coroutineContext = coroutineContext,
task = {
player.runOnApplicationLooper {
if (player.playWhenReady) {
notifyPos(player.currentPosition.milliseconds)
}
}
},
)

private val uptimeHeartbeat = Heartbeat(
startDelay = HEART_BEAT_DELAY,
period = UPTIME_PERIOD,
coroutineContext = coroutineContext,
task = {
player.runOnApplicationLooper {
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 {
Expand All @@ -61,50 +76,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<Unit> {
return async {
delay(HEART_BEAT_DELAY)

while (isActive && continueLooping()) {
runOnMain {
if (player.playWhenReady) {
task(player.currentPosition.milliseconds)
}
}

delay(period)
}
}
}

private fun <T> 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) {
Expand Down Expand Up @@ -228,7 +206,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package ch.srgssr.pillarbox.core.business
import ch.srgssr.pillarbox.core.business.integrationlayer.data.BlockReason
import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter
import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition
import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient.jsonSerializer
import ch.srgssr.pillarbox.player.network.PillarboxHttpClient.jsonSerializer
import kotlinx.serialization.SerializationException
import kotlin.test.Test
import kotlin.test.assertEquals
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ class CommandersActStreamingTest {
return mockk<ExoPlayer> {
val player = this

every { player.playWhenReady } returns true
every { player.isPlaying } returns isPlaying
every { player.currentPosition } returns currentPosition
every { player.isCurrentMediaItemLive } returns isCurrentMediaItemLive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,20 @@ 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
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
Expand All @@ -71,8 +70,6 @@ class CommandersActTrackerIntegrationTest {
commandersAct = mockk(relaxed = true)
testDispatcher = UnconfinedTestDispatcher()

Dispatchers.setMain(testDispatcher)

val context = ApplicationProvider.getApplicationContext<Context>()
val mediaItemTrackerRepository = DefaultMediaItemTrackerRepository(
trackerRepository = MediaItemTrackerRepository(),
Expand All @@ -84,23 +81,21 @@ class CommandersActTrackerIntegrationTest {
}

val mediaCompositionWithFallbackService = LocalMediaCompositionWithFallbackService(context)

player = DefaultPillarbox(
context = context,
mediaItemTrackerRepository = mediaItemTrackerRepository,
mediaCompositionService = mediaCompositionWithFallbackService,
clock = clock,
// 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
Expand Down Expand Up @@ -590,16 +585,14 @@ 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

TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY)

clock.advanceTime(2.seconds.inWholeMilliseconds)
advanceTimeBy(2.seconds)

TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class ComScoreTrackerIntegrationTest {
mediaItemTrackerRepository = mediaItemTrackerRepository,
mediaCompositionService = mediaCompositionWithFallbackService,
clock = clock,
coroutineContext = EmptyCoroutineContext,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ package ch.srgssr.pillarbox.core.business.utils
import android.content.Context
import android.net.Uri
import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition
import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient
import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService
import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService
import ch.srgssr.pillarbox.player.network.PillarboxHttpClient

internal class LocalMediaCompositionWithFallbackService(
context: Context,
Expand All @@ -19,7 +19,7 @@ internal class LocalMediaCompositionWithFallbackService(

init {
val json = context.assets.open("media-compositions.json").bufferedReader().use { it.readText() }
mediaCompositions = DefaultHttpClient.jsonSerializer.decodeFromString(json)
mediaCompositions = PillarboxHttpClient.jsonSerializer.decodeFromString(json)
}

override suspend fun fetchMediaComposition(uri: Uri): Result<MediaComposition> {
Expand Down
Loading

0 comments on commit f65758a

Please sign in to comment.