Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Uplynk connector Ad events #28

Merged
merged 6 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.theoplayer.android.connector.uplynk.internal

import com.theoplayer.android.api.ads.Ad
import com.theoplayer.android.api.ads.AdBreakInit
import com.theoplayer.android.api.ads.AdInit
import com.theoplayer.android.api.ads.ServerSideAdIntegrationController
import com.theoplayer.android.connector.uplynk.network.UplynkAd
import com.theoplayer.android.connector.uplynk.network.UplynkAdBreak
import java.util.WeakHashMap
import kotlin.time.Duration
import kotlin.time.DurationUnit

private val Duration.secToMs: Int
get() = this.toInt(DurationUnit.MILLISECONDS)

class AdHandler(private val controller: ServerSideAdIntegrationController) {
private val scheduledAds = WeakHashMap<UplynkAd, Ad>()

fun createAdBreak(adBreak: UplynkAdBreak) {
val adBreakInit = AdBreakInit(adBreak.timeOffset.secToMs, adBreak.duration.secToMs)
val currentAdBreak = controller.createAdBreak(adBreakInit)
adBreak.ads.forEach {
val adInit = AdInit(type = adBreak.type, duration = it.duration.secToMs)
scheduledAds[it] = controller.createAd(adInit, currentAdBreak)
}
}

fun onAdBegin(uplynkAd: UplynkAd) {
val ad = scheduledAds[uplynkAd]
checkNotNull(ad) { "Cannot find an ad $uplynkAd" }
controller.beginAd(ad)
}

fun onAdEnd(uplynkAd: UplynkAd) {
val ad = scheduledAds[uplynkAd]
checkNotNull(ad) { "Cannot find an ad $uplynkAd" }
controller.endAd(ad)
}

fun onAdProgressUpdate(currentAd: UplynkAdState, adBreak: UplynkAdBreak, time: Duration) {
val ad = scheduledAds[currentAd.ad]
checkNotNull(ad) { "Cannot find an ad: $currentAd" }

val playedDuration = adBreak.ads
.takeWhile { it != currentAd.ad }
.fold(Duration.ZERO) { sum, item ->
sum + item.duration
}

val startTime = adBreak.timeOffset + playedDuration
val progress = ((time - startTime) / currentAd.ad.duration).coerceIn(0.0, 1.0)

controller.updateAdProgress(ad, progress)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package com.theoplayer.android.connector.uplynk.internal
import com.theoplayer.android.api.THEOplayerView
import com.theoplayer.android.api.ads.ServerSideAdIntegrationController
import com.theoplayer.android.api.ads.ServerSideAdIntegrationHandler
import com.theoplayer.android.api.event.player.PlayerEventTypes
import com.theoplayer.android.api.player.Player
import com.theoplayer.android.api.source.SourceDescription
import com.theoplayer.android.connector.uplynk.UplynkSsaiDescription
import com.theoplayer.android.connector.uplynk.internal.network.UplynkApi
import kotlin.time.DurationUnit
import kotlin.time.toDuration

internal class UplynkAdIntegration(
private val theoplayerView: THEOplayerView,
Expand All @@ -15,11 +18,22 @@ internal class UplynkAdIntegration(
private val uplynkDescriptionConverter: UplynkSsaiDescriptionConverter,
private val uplynkApi: UplynkApi
) : ServerSideAdIntegrationHandler {

private var adScheduler: UplynkAdScheduler? = null
MattiasBuelens marked this conversation as resolved.
Show resolved Hide resolved
private val player: Player
get() = theoplayerView.player

init {
player.addEventListener(PlayerEventTypes.TIMEUPDATE) {
adScheduler?.onTimeUpdate(it.currentTime.toDuration(DurationUnit.SECONDS))
}
}

override suspend fun resetSource() {
adScheduler = null
}

override suspend fun setSource(source: SourceDescription): SourceDescription {
adScheduler = null

val uplynkSource = source.sources.singleOrNull { it.ssai is UplynkSsaiDescription }
val ssaiDescription = uplynkSource?.ssai as? UplynkSsaiDescription ?: return source
Expand All @@ -29,7 +43,9 @@ internal class UplynkAdIntegration(
.let { uplynkApi.preplay(it) }
.also {
try {
eventDispatcher.dispatchPreplayEvents(it.parseExternalResponse())
val response = it.parseExternalResponse()
eventDispatcher.dispatchPreplayEvents(response)
adScheduler = UplynkAdScheduler(response.ads.breaks, AdHandler(controller))
} catch (e: Exception) {
eventDispatcher.dispatchPreplayFailure(e)
controller.error(e)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.theoplayer.android.connector.uplynk.internal

import com.theoplayer.android.connector.uplynk.network.UplynkAd
import com.theoplayer.android.connector.uplynk.network.UplynkAdBreak
import kotlin.time.Duration

data class UplynkAdBreakState(
val adBreak: UplynkAdBreak,
var state: AdBreakState,
val ads: List<UplynkAdState> = adBreak.ads.map { UplynkAdState(it, AdState.NOT_PLAYED) }
)

data class UplynkAdState(
val ad: UplynkAd,
var state: AdState
)

enum class AdState {
NOT_PLAYED,
STARTED,
COMPLETED,
}

enum class AdBreakState {
NOT_PLAYED,
STARTED,
FINISHED,
}

class UplynkAdScheduler(
uplynkAdBreaks: List<UplynkAdBreak>,
private val adHandler: AdHandler
) {
private val adBreaks = uplynkAdBreaks.map {
adHandler.createAdBreak(it)
UplynkAdBreakState(it, AdBreakState.NOT_PLAYED)
}

private fun moveToState(
currentAdBreak: UplynkAdBreakState,
newState: AdBreakState
) {
if (currentAdBreak.state == newState) return
currentAdBreak.state = newState
if (currentAdBreak.state == AdBreakState.FINISHED) {
endAllStartedAds(currentAdBreak)
}
}

fun onTimeUpdate(time: Duration) {
val currentAdBreak =
adBreaks.firstOrNull { time in it.adBreak.timeOffset..(it.adBreak.timeOffset + it.adBreak.duration) }

if (currentAdBreak != null) {
val currentAd = beginCurrentAdBreak(currentAdBreak, time)
endAllStartedAds(currentAdBreak, currentAd)
beginCurrentAd(currentAdBreak, currentAd, time)
endAllAdBreaksExcept(currentAdBreak)
} else {
endAllAdBreaks()
}
}

private fun beginCurrentAd(
currentAdBreak: UplynkAdBreakState,
currentAd: UplynkAdState?,
time: Duration
) {
checkNotNull(currentAd) {
"Current ad break exists but there is no current ad in $currentAdBreak"
}
when (currentAd.state) {
AdState.COMPLETED,
AdState.NOT_PLAYED -> moveAdToState(currentAd, AdState.STARTED)

AdState.STARTED -> adHandler.onAdProgressUpdate(currentAd, currentAdBreak.adBreak, time)
}
}

private fun moveAdToState(currentAd: UplynkAdState, state: AdState) {
if (currentAd.state == state) return
currentAd.state = state
when (currentAd.state) {
AdState.NOT_PLAYED -> {}
AdState.STARTED -> adHandler.onAdBegin(currentAd.ad)
AdState.COMPLETED -> adHandler.onAdEnd(currentAd.ad)
}
}

private fun endAllStartedAds(
currentAdBreak: UplynkAdBreakState,
currentAd: UplynkAdState? = null
) {
currentAdBreak.ads
.takeWhile { currentAd == null || it != currentAd }
.forEach {
moveAdToState(it, AdState.COMPLETED)
}
}

private fun endAllAdBreaks() = adBreaks
.filter { it.state == AdBreakState.STARTED }
.forEach { moveToState(it, AdBreakState.FINISHED) }

private fun endAllAdBreaksExcept(currentAdBreak: UplynkAdBreakState?) = adBreaks
.filter { it != currentAdBreak }
.filter { it.state == AdBreakState.STARTED }
.forEach { moveToState(it, AdBreakState.FINISHED) }

private fun beginCurrentAdBreak(
currentAdBreak: UplynkAdBreakState,
time: Duration
): UplynkAdState? {
val currentAd = findCurrentAd(currentAdBreak, time)
if (currentAdBreak.state != AdBreakState.STARTED) {
moveToState(currentAdBreak, AdBreakState.STARTED)
}
return currentAd
}

private fun findCurrentAd(adBreak: UplynkAdBreakState, time: Duration): UplynkAdState? {
var adStart = adBreak.adBreak.timeOffset
for (ad in adBreak.ads) {
val adEnd = adStart + ad.ad.duration
if (time in adStart..adEnd) {
return ad
}
adStart = adEnd
}

return null
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.theoplayer.android.connector.uplynk.internal.network

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit

object DurationToSecDeserializer : KSerializer<Duration> {

override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("DurationSeconds", PrimitiveKind.DOUBLE)

override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeDouble(value.toDouble(DurationUnit.SECONDS))
}

override fun deserialize(decoder: Decoder): Duration {
return decoder.decodeDouble().seconds
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,12 @@ data class PreplayResponse(
* * Possible return value: 'https://content-ause2.uplynk.com/'
*
*/
val prefix: String)
val prefix: String,

/**
* Contains ad information, such as break offsets and non-video ads.
*
* (**NonNull**)
*
*/
val ads: UplynkAds)
MattiasBuelens marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.theoplayer.android.connector.uplynk.network

import com.theoplayer.android.connector.uplynk.internal.network.DurationToSecDeserializer
import kotlinx.serialization.Serializable
import kotlin.time.Duration


/**
* Represents details about an ad, including its API framework, companion ads, creative details, and other properties.
*
* @property apiFramework Indicates the API Framework for the ad (e.g., VPAID). A null value is returned for ads that do not have an API Framework.
* @property companions List of companion ads that go with the ad. Companion ads are also ad objects.
* @property mimeType Indicates the ad's Internet media type (aka mime-type).
* @property creative If applicable, indicates the creative to display. For video ads, this is the asset ID from the CMS. For VPAID ads, this is the URL to the VPAID JS or SWF.
* @property events Object containing all of the events for this ad. Each event type contains an array of URLs.
* @property width If applicable, indicates the width of the creative. This parameter reports "0" for the width/height of video ads.
* @property height If applicable, indicates the height of the creative.
* @property duration Indicates the duration, in seconds, of an ad's encoded video. For VPAID ads, this parameter reports the duration returned from the ad server.
* @property extensions Contains the custom set of VAST extensions returned by the ad server. Each custom extension is reported as an object.
* @property fwParameters FreeWheel only: If the ad response provided by FreeWheel contains creative parameters, they are reported as name-value pairs within this object.
*/
@Serializable
data class UplynkAd(
/**
* Indicates the API Framework for the ad (e.g., VPAID).
* A null value is returned for ads that do not have an API Framework.
*/
val apiFramework: String?,

/**
* List of companion ads that go with the ad.
* Companion ads are also ad objects.
*/
val companions: List<UplynkAd>,

/**
* Indicates the ad's Internet media type (aka mime-type).
*/
val mimeType: String,

/**
* If applicable, indicates the creative to display.
* Video Ad (CMS): Indicates the asset ID for the video ad pushed from the CMS.
* Video Ad (VPAID): Indicates the URL to the VPAID JS or SWF.
*/
val creative: String,

/**
* Object containing all of the events for this ad.
* Each event type contains an array of URLs.
*/
val events: Map<String, List<String>>? = null,

/**
* If applicable, indicates the width of the creative.
* This parameter reports "0" for the width/height of video ads.
*/
val width: Float,

/**
* If applicable, indicates the height of the creative.
*/
val height: Float,

/**
* Indicates the duration, in seconds, of an ad's encoded video.
* VPAID: For VPAID ads, this parameter reports the duration returned from the ad server.
*/
@Serializable(with = DurationToSecDeserializer::class)
val duration: Duration,

/**
* Contains the custom set of VAST extensions returned by the ad server.
* Each custom extension is reported as an object.
*/
val extensions: List<Map<String, String>>? = null,

/**
* FreeWheel only: If the ad response provided by FreeWheel contains creative parameters,
* they are reported as name-value pairs within this object.
*/
val fwParameters: Map<String, String>? = null
)
Loading