diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65ea9f36..68c760d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,6 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Run unit tests run: ./gradlew testReleaseUnitTest + env: + YOSPACE_USERNAME: ${{ secrets.YOSPACE_USERNAME }} + YOSPACE_PASSWORD: ${{ secrets.YOSPACE_PASSWORD }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 60a23a08..c353be07 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,6 +39,9 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build API documentation with Dokka run: ./gradlew :dokkaHtmlMultiModule --no-configuration-cache + env: + YOSPACE_USERNAME: ${{ secrets.YOSPACE_USERNAME }} + YOSPACE_PASSWORD: ${{ secrets.YOSPACE_PASSWORD }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5003a6f6..f00db872 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,3 +33,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }} REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }} + YOSPACE_USERNAME: ${{ secrets.YOSPACE_USERNAME }} + YOSPACE_PASSWORD: ${{ secrets.YOSPACE_PASSWORD }} diff --git a/app/build.gradle b/app/build.gradle index 1260da3f..db22bf71 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,6 +10,7 @@ android { defaultConfig { applicationId "com.theoplayer.android.connector" minSdk 21 + targetSdk 34 versionCode 1 versionName "1.0" } @@ -47,4 +48,6 @@ dependencies { implementation "com.nielsenappsdk.global:ad:9.2.0.0" implementation project(':connectors:analytics:comscore') implementation "com.comscore:android-analytics:$comscoreVersion" + implementation project(':connectors:yospace') + implementation "com.yospace:admanagement-sdk:$yospaceVersion" } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 24da06a1..f40ce51f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + ) { + val adBreak = event.adBreak + Log.d( + TAG, + "${event.type} - " + + "timeOffset=${adBreak.timeOffset}, ads=${adBreak.ads.size}," + + " maxDuration=${adBreak.maxDuration}," + + " currentTime=${theoplayerView.player.currentTime}" + ) + } + + fun onAdEvent(event: SingleAdEvent<*>) { + val ad = event.ad ?: return + Log.d( + TAG, + "${event.type} - " + + "id=${ad.id}, type=${ad.type}, adBreak.timeOffset=${ad.adBreak?.timeOffset}," + + (if (ad is LinearAd) " duration=${ad.duration}," else "") + + " currentTime=${theoplayerView.player.currentTime}" ) - .ads( - GoogleImaAdDescription.Builder("https://cdn.theoplayer.com/demos/ads/vast/dfp-linear-inline-no-skip.xml") - .timeOffset("5") - .build() - ).metadata(MetadataDescription(mapOf("title" to "BigBuckBunny with Google IMA ads"))) - .build() - nielsenConnector?.updateMetadata(hashMapOf( - "assetid" to "C112233", - "program" to "BigBuckBunny with Google IMA ads" - )) + } + + fun selectSource(view: View) { + val sourceNames = sources.map { it.name }.toTypedArray() + val selectedIndex = sources.indexOf(selectedSource) + AlertDialog.Builder(this) + .setTitle("Select source") + .setSingleChoiceItems(sourceNames, selectedIndex) { dialog, which -> + setSource(sources[which]) + dialog.dismiss() + } + .create() + .show() + } + + private fun setSource(source: Source) { + selectedSource = source + theoplayerView.player.source = source.sourceDescription + nielsenConnector.updateMetadata(source.nielsenMetadata) } fun playPause(view: View) { @@ -177,4 +230,4 @@ class MainActivity : AppCompatActivity() { super.onDestroy() theoplayerView.onDestroy() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/theoplayer/android/connector/Sources.kt b/app/src/main/java/com/theoplayer/android/connector/Sources.kt new file mode 100644 index 00000000..bba4a929 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/connector/Sources.kt @@ -0,0 +1,104 @@ +package com.theoplayer.android.connector + +import com.theoplayer.android.api.source.SourceDescription +import com.theoplayer.android.api.source.SourceType +import com.theoplayer.android.api.source.TypedSource +import com.theoplayer.android.api.source.addescription.GoogleImaAdDescription +import com.theoplayer.android.api.source.metadata.MetadataDescription +import com.theoplayer.android.connector.yospace.YospaceSsaiDescription +import com.theoplayer.android.connector.yospace.YospaceStreamType + +data class Source( + val name: String, + val sourceDescription: SourceDescription, + val nielsenMetadata: HashMap = hashMapOf() +) + +val sources: List by lazy { + listOf( + Source( + name = "BigBuckBunny with Google IMA ads", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder("https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8") + .build() + ) + .ads( + GoogleImaAdDescription.Builder("https://cdn.theoplayer.com/demos/ads/vast/dfp-linear-inline-no-skip.xml") + .timeOffset("5") + .build() + ) + .metadata(MetadataDescription(mapOf("title" to "BigBuckBunny with Google IMA ads"))) + .build(), + nielsenMetadata = hashMapOf( + "assetid" to "C112233", + "program" to "BigBuckBunny with Google IMA ads" + ) + ), + Source( + name = "Yospace HLS VOD", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder( + "https://csm-e-sdk-validation.bln1.yospace.com/csm/access/156611618/c2FtcGxlL21hc3Rlci5tM3U4?yo.av=4" + ) + .type(SourceType.HLS) + .ssai(YospaceSsaiDescription(streamType = YospaceStreamType.VOD)) + .build() + ) + .build() + ), + Source( + name = "Yospace HLS Live", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder( + "https://csm-e-sdk-validation.bln1.yospace.com/csm/extlive/yospace02,hlssample42.m3u8?yo.br=true&yo.av=4" + ) + .type(SourceType.HLS) + .ssai(YospaceSsaiDescription(streamType = YospaceStreamType.LIVE)) + .build() + ) + .build() + ), + Source( + name = "Yospace HLS DVRLive", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder( + "https://csm-e-sdk-validation.bln1.yospace.com/csm/extlive/yospace02,hlssample42.m3u8?yo.br=true&yo.lp=true&yo.av=4" + ) + .type(SourceType.HLS) + .ssai(YospaceSsaiDescription(streamType = YospaceStreamType.LIVEPAUSE)) + .build() + ) + .build() + ), + Source( + name = "Yospace DASH Live", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder( + "https://csm-e-sdk-validation.bln1.yospace.com/csm/extlive/yosdk01,t2-dash.mpd?yo.br=true&yo.av=4" + ) + .type(SourceType.DASH) + .ssai(YospaceSsaiDescription(streamType = YospaceStreamType.LIVE)) + .build() + ) + .build() + ), + Source( + name = "Yospace DASH DVRLive", + sourceDescription = SourceDescription + .Builder( + TypedSource.Builder( + "https://csm-e-sdk-validation.bln1.yospace.com/csm/extlive/yosdk01,dash.mpd?yo.br=true&yo.lp=true&yo.jt=1000&yo.av=4" + ) + .type(SourceType.DASH) + .ssai(YospaceSsaiDescription(streamType = YospaceStreamType.LIVEPAUSE)) + .build() + ) + .build() + ) + ) +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0071c604..8ba4d19a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -27,7 +27,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="set source" - android:onClick="setSource"/> + android:onClick="selectSource"/> private var lastPosition: Long = -1 - private var appSdk: AppSdk? = null + private var appSdk: AppSdk private var sessionInProgress: Boolean = false private val mainHandler = Handler(Looper.getMainLooper()) @@ -83,7 +83,7 @@ class NielsenHandler( maybeSendPlayEvent() } onPause = EventListener { - appSdk?.stop() + appSdk.stop() } onDurationChange = EventListener { maybeSendPlayEvent() @@ -96,19 +96,19 @@ class NielsenHandler( } onLoadedMetadata = EventListener { // contentMetadataObject contains the JSON metadata for the content being played - appSdk?.loadMetadata(buildMetadata()) + appSdk.loadMetadata(buildMetadata()) } onCueEnterId3 = EventListener { event -> event.cue.content?.optJSONObject("content")?.let { cueContent -> handleNielsenId3Payload(cueContent) { - appSdk?.sendID3(it) + appSdk.sendID3(it) } } } onCueEnterEmsg = EventListener { event -> event.cue.content?.optJSONArray("content")?.let { cueContent -> handleNielsenEmsgPayload(cueContent) { - appSdk?.sendID3(it) + appSdk.sendID3(it) } } } @@ -128,8 +128,8 @@ class NielsenHandler( val ad = event.ad if (ad?.type == "linear") { val timeOffset = ad.adBreak?.timeOffset ?: 0 - appSdk?.stop() - appSdk?.loadMetadata(buildMetadata().apply { + appSdk.stop() + appSdk.loadMetadata(buildMetadata().apply { put( PROP_TYPE, when { timeOffset == 0 -> PROP_PREROLL @@ -144,7 +144,7 @@ class NielsenHandler( onAdEnd = EventListener { event -> val ad = event.ad if (ad?.type == "linear") { - appSdk?.stop() + appSdk.stop() } } @@ -170,7 +170,7 @@ class NielsenHandler( } fun updateMetadata(metadata: Map) { - appSdk?.loadMetadata(buildMetadata(metadata)) + appSdk.loadMetadata(buildMetadata(metadata)) } fun destroy() { @@ -213,7 +213,7 @@ class NielsenHandler( sessionInProgress = true // stream starts - appSdk?.play(JSONObject().apply { + appSdk.play(JSONObject().apply { put(PROP_CHANNEL_NAME, player.src) }) } @@ -223,7 +223,7 @@ class NielsenHandler( if (sessionInProgress) { lastPosition = -1 sessionInProgress = false - appSdk?.end() + appSdk.end() } } diff --git a/connectors/yospace/.gitignore b/connectors/yospace/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/connectors/yospace/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/connectors/yospace/README.md b/connectors/yospace/README.md new file mode 100644 index 00000000..acd09a20 --- /dev/null +++ b/connectors/yospace/README.md @@ -0,0 +1,55 @@ +# THEOplayer Yospace Connector for Android + +The Yospace connector provides a Yospace integration for THEOplayer. + +## Installation + +1. Add the THEOplayer Maven repository to your project-level `settings.gradle` file: + ```groovy + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = uri("https://maven.theoplayer.com/releases/") } + } + } + ``` +2. Add the Yospace Maven repository to that same `settings.gradle` file. + Please refer to the [Yospace Ad Management SDK documentation][yospace-userguide] (section 7.2. Downloads > Ad Management SDK) for instructions. +3. Add THEOplayer, the Yospace Ad Management SDK and the Yospace Connector as dependencies in your module-level `build.gradle` file: + ```groovy + dependencies { + implementation "com.theoplayer.theoplayer-sdk-android:core:7.+" + implementation "com.yospace:admanagement-sdk:3.+" + implementation "com.theoplayer.android-connector:yospace:7.+" + } + ``` + +## Usage + +First, follow [the getting started guide for the THEOplayer Android SDK][android-getting-started] +to set up a `THEOplayerView` in your app. + +Then, create the `YospaceConnector`: + +```kotlin +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.connector.yospace.YospaceConnector + +val theoPlayerView = findViewById(R.id.theoplayer) +val yospaceConnector = YospaceConnector(theoPlayerView) +``` + +Finally, set the THEOplayer source to a `SourceDescription` with a `YospaceSsaiDescription` as its `ssai` description: +```kotlin +theoplayerView.player.source = SourceDescription.Builder( + TypedSource.Builder("https://example.com/stream.m3u8") + .ssai( + YospaceSsaiDescription(streamType = YospaceStreamType.LIVE) + ) + .build() +).build() +``` + +[yospace-userguide]: https://developer.yospace.com/sdk-documentation/android/userguide/latest/en/index-en.html +[android-getting-started]: https://www.theoplayer.com/docs/theoplayer/getting-started/sdks/android/getting-started/ diff --git a/connectors/yospace/build.gradle b/connectors/yospace/build.gradle new file mode 100644 index 00000000..0743e5dd --- /dev/null +++ b/connectors/yospace/build.gradle @@ -0,0 +1,63 @@ +import org.jetbrains.dokka.gradle.AbstractDokkaLeafTask + +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'org.jetbrains.dokka' +} +apply from: "$rootDir/connectors/publish.gradle" + +android { + namespace 'com.theoplayer.android.connector.yospace' + compileSdk 34 + + defaultConfig { + minSdk 21 + consumerProguardFiles "consumer-rules.pro" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "LIBRARY_VERSION", "\"${sdkVersion}\"") + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + compileOnly "com.theoplayer.theoplayer-sdk-android:core:$sdkVersion" + compileOnly("com.yospace:admanagement-sdk") { + version { + strictly("[3.6, 4.0)") + prefer(yospaceVersion) + } + } + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" + + // Tests + testImplementation "androidx.test:core:1.5.0" + testImplementation "androidx.test.ext:junit:1.1.5" + testImplementation "com.theoplayer.theoplayer-sdk-android:core:$sdkVersion" + testImplementation "com.yospace:admanagement-sdk:$yospaceVersion" +} + +tasks.withType(AbstractDokkaLeafTask.class).configureEach { + moduleName = "Yospace Connector" + + dokkaSourceSets.named("main") { + samples.from(project.fileTree("src/test/java/")) + } +} diff --git a/connectors/yospace/consumer-rules.pro b/connectors/yospace/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/connectors/yospace/proguard-rules.pro b/connectors/yospace/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/connectors/yospace/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/connectors/yospace/src/main/AndroidManifest.xml b/connectors/yospace/src/main/AndroidManifest.xml new file mode 100644 index 00000000..74b7379f --- /dev/null +++ b/connectors/yospace/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceConnector.kt b/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceConnector.kt new file mode 100644 index 00000000..98732101 --- /dev/null +++ b/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceConnector.kt @@ -0,0 +1,140 @@ +package com.theoplayer.android.connector.yospace + +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ad +import com.theoplayer.android.api.ads.ServerSideAdIntegrationController +import com.theoplayer.android.api.source.ssai.CustomSsaiDescriptionRegistry +import com.theoplayer.android.connector.yospace.internal.YospaceAdIntegration +import com.yospace.admanagement.AdBreak +import com.yospace.admanagement.Advert +import com.yospace.admanagement.AnalyticEventObserver +import com.yospace.admanagement.Session +import com.yospace.admanagement.TrackingErrors +import java.util.concurrent.CopyOnWriteArrayList + +internal const val TAG = "YospaceConnector" +internal const val USER_AGENT = "THEOplayerYospaceConnector/${BuildConfig.LIBRARY_VERSION}" + +/** + * A connector for the Yospace Ad Management SDK. + * + * @param theoplayerView + * The THEOplayer view, which will be connected to the created connector. + * @param uiHandler + * A handler for updating the UI. By default, this creates a [YospaceDefaultUiHandler]. + * + * @sample com.theoplayer.android.connector.yospace.samples.createYospaceConnector + * + * @see [Yospace Ad Management SDK v3 for Android Developers](https://developer.yospace.com/sdk-documentation/android/userguide/latest/en/index-en.html) + * (requires login) + */ +class YospaceConnector @JvmOverloads constructor( + private val theoplayerView: THEOplayerView, + private val uiHandler: YospaceUiHandler = YospaceDefaultUiHandler(theoplayerView) +) { + private val analyticEventObservers = CopyOnWriteArrayList() + private val listeners = CopyOnWriteArrayList() + + private lateinit var integration: YospaceAdIntegration + + init { + theoplayerView.player.ads.registerServerSideIntegration(INTEGRATION_ID, this::setupIntegration) + } + + private fun setupIntegration(controller: ServerSideAdIntegrationController): YospaceAdIntegration { + val integration = YospaceAdIntegration( + theoplayerView, + uiHandler, + controller, + ForwardingAnalyticEventObserver(), + ForwardingListener() + ) + this.integration = integration + return integration + } + + /** + * Registers an [AnalyticEventObserver] on the Yospace [Session]. + */ + fun registerAnalyticEventObserver(observer: AnalyticEventObserver) { + analyticEventObservers.add(observer) + } + + /** + * Removes a previously registered [AnalyticEventObserver]. + */ + fun unregisterAnalyticEventObserver(observer: AnalyticEventObserver) { + analyticEventObservers.remove(observer) + } + + /** + * Registers a [YospaceListener] to receive events from this connector. + */ + fun addListener(listener: YospaceListener) { + listeners.add(listener) + } + + /** + * Removes a previously registered [YospaceListener]. + */ + fun removeListener(listener: YospaceListener) { + listeners.remove(listener) + } + + private inner class ForwardingAnalyticEventObserver : AnalyticEventObserver { + override fun onAdvertBreakStart(adBreak: AdBreak?, session: Session) { + analyticEventObservers.forEach { it.onAdvertBreakStart(adBreak, session) } + } + + override fun onAdvertBreakEnd(session: Session) { + analyticEventObservers.forEach { it.onAdvertBreakEnd(session) } + } + + override fun onAdvertStart(advert: Advert, session: Session) { + analyticEventObservers.forEach { it.onAdvertStart(advert, session) } + } + + override fun onAdvertEnd(session: Session) { + analyticEventObservers.forEach { it.onAdvertEnd(session) } + } + + override fun onAnalyticUpdate(session: Session) { + analyticEventObservers.forEach { it.onAnalyticUpdate(session) } + } + + override fun onEarlyReturn(adBreak: AdBreak, session: Session) { + analyticEventObservers.forEach { it.onEarlyReturn(adBreak, session) } + } + + override fun onSessionError(error: AnalyticEventObserver.SessionError, session: Session) { + analyticEventObservers.forEach { it.onSessionError(error, session) } + } + + override fun onTrackingEvent(type: String, session: Session) { + analyticEventObservers.forEach { it.onTrackingEvent(type, session) } + } + + override fun onTrackingError(error: TrackingErrors.Error, session: Session) { + analyticEventObservers.forEach { it.onTrackingError(error, session) } + } + } + + private inner class ForwardingListener : YospaceListener { + override fun onSessionAvailable() { + listeners.forEach { it.onSessionAvailable() } + } + } + + companion object { + /** + * The integration identifier for the Yospace connector. + * + * Ads created by this connector have this value as their [custom integration][Ad.getCustomIntegration]. + */ + const val INTEGRATION_ID = "yospace" + + init { + CustomSsaiDescriptionRegistry.register(INTEGRATION_ID, YospaceSsaiDescriptionSerializer()) + } + } +} diff --git a/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceDefaultUiHandler.kt b/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceDefaultUiHandler.kt new file mode 100644 index 00000000..9077bfd3 --- /dev/null +++ b/connectors/yospace/src/main/java/com/theoplayer/android/connector/yospace/YospaceDefaultUiHandler.kt @@ -0,0 +1,102 @@ +package com.theoplayer.android.connector.yospace + +import android.graphics.BitmapFactory +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import com.theoplayer.android.api.THEOplayerView +import com.yospace.admanagement.LinearCreative +import com.yospace.admanagement.NonLinearCreative +import com.yospace.admanagement.Resource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URL + +/** + * A default UI handler for the Yospace connector. + * + * This handler shows a linear clickthrough button in the top-right corner, + * and shows clickable non-linear images in the top-left corner. + * + * If you want to customize the UI, you can implement your own [YospaceUiHandler] + * and pass it to the [YospaceConnector] constructor. + */ +class YospaceDefaultUiHandler( + theoplayerView: THEOplayerView +) : YospaceUiHandler { + private val parentView: ViewGroup = theoplayerView.findViewById(R.id.theo_ads_container) + private val adsContainer: ViewGroup = + (LayoutInflater.from(parentView.context).inflate(R.layout.default_ad_overlay, parentView, false) as ViewGroup).also { + parentView.addView(it) + } + private val linearClickThroughButton = adsContainer.findViewById