From 21b35c762468d7457974f660f53fb5341ac9b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Tue, 19 Mar 2024 13:19:07 +0100 Subject: [PATCH] Refactor media loading (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller --- gradle/libs.versions.toml | 1 - pillarbox-core-business/build.gradle.kts | 1 - pillarbox-core-business/docs/README.md | 82 +--- .../core/business/DefaultPillarbox.kt | 36 +- .../MediaCompositionMediaItemSource.kt | 233 ---------- .../pillarbox/core/business/MediaItemUrn.kt | 41 -- .../core/business/SRGMediaItemBuilder.kt | 148 ++++++ .../core/business/TrackerDataProvider.kt | 30 -- .../business/akamai/AkamaiTokenDataSource.kt | 20 +- .../integrationlayer/ImageScalingService.kt | 34 ++ .../integrationlayer/ResourceSelector.kt | 32 ++ .../integrationlayer/data/MediaUrn.kt | 12 - .../DefaultMediaCompositionDataSource.kt | 44 -- .../service/HttpMediaCompositionService.kt | 31 ++ .../service/MediaCompositionDataSource.kt | 20 - .../service/MediaCompositionService.kt | 21 + .../source/DefaultMediaMetaDataProvider.kt | 34 ++ .../core/business/source/SRGAssetLoader.kt | 228 ++++++++++ .../business/tracker/SRGEventLoggerTracker.kt | 4 - .../commandersact/CommandersActTracker.kt | 7 - .../tracker/comscore/ComScoreTracker.kt | 7 - .../core/business/MediaItemUrnTest.kt | 122 ++++- ...temSourceTest.kt => SRGAssetLoaderTest.kt} | 124 ++--- .../ImageScalingServiceTest.kt | 3 +- .../ResourceSelectorTest.kt | 4 +- .../integrationlayer/data/MediaUrnTest.kt | 23 - .../DefaultMediaCompositionDataSourceTest.kt | 87 ---- .../tracker/SRGEventLoggerTrackerTest.kt | 1 - .../CommandersActStreamingTest.kt | 6 +- .../CommandersActTrackerIntegrationTest.kt | 35 +- .../commandersact/CommandersActTrackerTest.kt | 20 - .../ComScoreTrackerIntegrationTest.kt | 18 - .../tracker/comscore/ComScoreTrackerTest.kt | 43 -- pillarbox-demo-shared/build.gradle.kts | 2 - .../pillarbox/demo/shared/data/DemoItem.kt | 56 ++- .../demo/shared/data/MixedMediaItemSource.kt | 31 -- .../pillarbox/demo/shared/data/Playlist.kt | 5 + .../pillarbox/demo/shared/di/PlayerModule.kt | 34 +- .../demo/shared/source/CustomAssetLoader.kt | 61 +++ pillarbox-demo/build.gradle.kts | 1 - .../demo/ui/player/SimplePlayerViewModel.kt | 9 +- .../ui/showcases/layouts/StoryViewModel.kt | 17 +- .../showcases/misc/SmoothSeekingShowcase.kt | 4 +- .../misc/UpdatableMediaItemViewModel.kt | 22 +- pillarbox-player/build.gradle.kts | 3 +- pillarbox-player/docs/MediaItemTracking.md | 48 +- pillarbox-player/docs/README.md | 36 +- .../player/IsPlayingAllTypeOfContentTest.kt | 42 +- .../pillarbox/player/MediaItemSourceTest.kt | 80 ---- .../player/utils/UniqueMediaItemSource.kt | 21 - .../pillarbox/player/PillarboxPlayer.kt | 70 ++- .../ch/srgssr/pillarbox/player/asset/Asset.kt | 22 + .../pillarbox/player/asset/AssetLoader.kt | 32 ++ .../pillarbox/player/asset/UrlAssetLoader.kt | 55 +++ .../pillarbox/player/data/MediaItemSource.kt | 20 - .../player/source/PillarboxMediaSource.kt | 103 ++--- .../source/PillarboxMediaSourceFactory.kt | 78 +++- .../player/tracker/CurrentMediaItemTracker.kt | 116 ++--- .../player/tracker/MediaItemTracker.kt | 2 +- .../player/tracker/MediaItemTrackerData.kt | 10 + .../player/PillarboxPlayerMediaItemTest.kt | 162 +++++++ .../TestPillarboxPlayerPlaybackSpeed.kt | 6 - .../CurrentMediaItemTrackerAreEqualTest.kt | 8 +- .../player/tracker/FakeAssetLoader.kt | 50 +++ .../player/tracker/FakeMediaItemSource.kt | 44 -- .../player/tracker/FakeMediaItemTracker.kt | 6 +- .../tracker/MediaItemTrackerDataTest.kt | 5 + .../player/tracker/MediaItemTrackerTest.kt | 423 +++++------------- .../tracker/MultiMediaItemTrackerUpdate.kt | 125 ------ pillarbox-ui/docs/README.md | 2 +- 70 files changed, 1640 insertions(+), 1723 deletions(-) delete mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt delete mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaItemUrn.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGMediaItemBuilder.kt delete mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelector.kt delete mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSource.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt delete mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionDataSource.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionService.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/DefaultMediaMetaDataProvider.kt create mode 100644 pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt rename pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/{MediaCompositionMediaItemSourceTest.kt => SRGAssetLoaderTest.kt} (63%) rename pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/{ => integrationlayer}/ImageScalingServiceTest.kt (92%) rename pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/{ => integrationlayer}/ResourceSelectorTest.kt (98%) delete mode 100644 pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSourceTest.kt delete mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/MixedMediaItemSource.kt create mode 100644 pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/source/CustomAssetLoader.kt delete mode 100644 pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/MediaItemSourceTest.kt delete mode 100644 pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/utils/UniqueMediaItemSource.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/Asset.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/AssetLoader.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/UrlAssetLoader.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/data/MediaItemSource.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPlayerMediaItemTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeAssetLoader.kt delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1b3d1b36..24a6abf38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,6 @@ kotlinx-kover-gradle = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", v kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } -ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } diff --git a/pillarbox-core-business/build.gradle.kts b/pillarbox-core-business/build.gradle.kts index 1ed2db21a..bd6eff51d 100644 --- a/pillarbox-core-business/build.gradle.kts +++ b/pillarbox-core-business/build.gradle.kts @@ -50,7 +50,6 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.ktor.client.mock) testImplementation(libs.mockk) testImplementation(libs.mockk.dsl) testRuntimeOnly(libs.robolectric) diff --git a/pillarbox-core-business/docs/README.md b/pillarbox-core-business/docs/README.md index bd7a16bf7..41e7f27d2 100644 --- a/pillarbox-core-business/docs/README.md +++ b/pillarbox-core-business/docs/README.md @@ -5,8 +5,8 @@ # Pillarbox Core Business module -Provides SRG SSR media URN `MediaItemSource` to Pillarbox. It basically converts an integration layer `MediaComposition` to a -playable `MediaItem`. +Provides SRG SSR media URN `MediaSource` to Pillarbox. It basically converts an integration layer `MediaComposition` to a +playable `MediaSource`. Supported contents are : @@ -14,10 +14,7 @@ Supported contents are : - Live streams (with and without DVR) - Token protected - DRM protected - -Unsupported contents are : - -- 360° content +- 360° content (Need to used the correct view) ## Integration @@ -34,27 +31,23 @@ More information can be found on the [top level README](../docs/README.md) In order to play an urn content with PillarboxPlayer, you have to create it like this : ```kotlin - val player = PillarboxPlayer( +val player = PillarboxPlayer( context = context, - mediaItemSource = MediaCompositionMediaItemSource(DefaultMediaCompositionDataSource(baseUrl = IlHost.PROD)), - /** - * Can be skipped if you never play token-protected content. - */ - dataSourceFactory = AkamaiTokenDataSource.Factory(), - /** - * Required Default SRG MediaItem trackers - */ + mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { + addAssetLoader(SRGAssetLoader(context)) + }, mediaItemTrackerProvider = DefaultMediaItemTrackerRepository() ) ``` -`MediaCompositionDataSourceImpl` retrieves a `MediaComposition` from the integration layer web service. - ### Create MediaItem with URN +In order to tell `PillarboxPlayer` to load a specific `MediaItem` with `PillarboxMediaSourceFactory`, the `MediaItem` has to be created with +`SRGMediaItemBuilder` : + ```kotlin val urnToPlay = "urn:rts:video:12345" -val itemToPlay = MediaItem.Builder().setMediaId(urnToPlay).build() +val itemToPlay = SRGMediaItemBuilder(urnToPlay).build() player.setMediaItem(itemToPlay) ``` @@ -85,58 +78,19 @@ All exceptions thrown by `MediaCompositionMediaItemSource` are caught by the pla }) ``` -## Add custom trackers - -`MediaItemTracker` can be added to the player. The data related to tracker have to be added during `MediaItem` creation inside -`MediaCompositionMediaItemSource`. `TrackerDataProvider` allow to add data for specific tracker. - -### Create custom MediaItemTracker - -```kotlin -class CustomTracker : MediaItemTracker { - - data class Data(val mediaComposition: MediaComposition) - - // implements here functions -} -``` - -### Create and add required custom tracker data - -```kotlin -val mediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = mediaCompositionDataSource, - trackerDataProvider = object : TrackerDataProvider { - override fun update(trackerData: MediaItemTrackerData, resource: Resource, chapter: Chapter, mediaComposition: MediaComposition) { - trackerData.putData(CustomTracker::class.java, CustomTracker.Data(mediaComposition)) - } - }) -``` - -### Inject custom tracker to the player - -```kotlin -val player = PillarboxPlayer(context = context, - mediaItemSource = mediaItemSource, - mediaItemTrackerProvider = DefaultMediaItemTrackerRepository().apply { - registerFactory(CustomTracker::class.java, CustomTracker.Factory()) - } -) -``` - ## Going further -As you see, the `MediaCompositionMediaItemSource` is created from an interface, so you can load custom MediaComposition easily into Pillarbox by -implementing your own `MediaCompositionDataSource`. +`PillarboxMediaSource` factory can be created with a `MediaCompositionService`, which can be used to retrieve a `MediaComposition`. You can create +you own `MediaCompositionService` to load the `MediaComposition` : ```kotlin -class MediaCompositionMapDataSource : MediaCompositionDataSource { - private val mediaCompositionMap = HashMap() +class MediaCompositionMapDataSource : MediaCompositionService { + private val mediaCompositionMap = mutableMapOf() - override suspend fun getMediaCompositionByUrn(urn: String): Result { - return mediaCompositionMap[urn]?.let { + override suspend fun fetchMediaComposition(uri: Uri): Result { + return mediaCompositionMap[uri]?.let { Result.success(it) - } ?: Result.failure(IOException("$urn not found")) + } ?: Result.failure(IOException("$uri not found")) } } ``` diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt index 41f4b639f..7325e0274 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/DefaultPillarbox.kt @@ -7,16 +7,16 @@ package ch.srgssr.pillarbox.core.business import android.content.Context import androidx.annotation.VisibleForTesting import androidx.media3.common.util.Clock -import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.LoadControl -import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenDataSource -import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService +import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService +import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.player.PillarboxLoadControl import ch.srgssr.pillarbox.player.PillarboxPlayer import ch.srgssr.pillarbox.player.SeekIncrement -import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerProvider import kotlin.time.Duration.Companion.seconds @@ -32,27 +32,22 @@ object DefaultPillarbox { * @param context The context. * @param seekIncrement The seek increment. * @param mediaItemTrackerRepository The provider of MediaItemTracker, by default [DefaultMediaItemTrackerRepository]. - * @param mediaItemSource The MediaItem source by default [MediaCompositionMediaItemSource]. - * @param dataSourceFactory The Http exoplayer data source factory, by default [AkamaiTokenDataSource.Factory]. - * @param loadControl The load control, by default [DefaultLoadControl]. + * @param mediaCompositionService The [MediaCompositionService] to use, by default [HttpMediaCompositionService]. + * @param loadControl The load control, by default [PillarboxLoadControl]. * @return [PillarboxPlayer] suited for SRG. */ operator fun invoke( context: Context, seekIncrement: SeekIncrement = defaultSeekIncrement, mediaItemTrackerRepository: MediaItemTrackerProvider = DefaultMediaItemTrackerRepository(), - mediaItemSource: MediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = DefaultMediaCompositionDataSource(), - ), - dataSourceFactory: DataSource.Factory = AkamaiTokenDataSource.Factory(), + mediaCompositionService: MediaCompositionService = HttpMediaCompositionService(), loadControl: LoadControl = PillarboxLoadControl(), ): PillarboxPlayer { return DefaultPillarbox( context = context, seekIncrement = seekIncrement, mediaItemTrackerRepository = mediaItemTrackerRepository, - mediaItemSource = mediaItemSource, - dataSourceFactory = dataSourceFactory, + mediaCompositionService = mediaCompositionService, loadControl = loadControl, clock = Clock.DEFAULT, ) @@ -64,9 +59,8 @@ object DefaultPillarbox { * @param context The context. * @param seekIncrement The seek increment. * @param mediaItemTrackerRepository The provider of MediaItemTracker, by default [DefaultMediaItemTrackerRepository]. - * @param mediaItemSource The MediaItem source by default [MediaCompositionMediaItemSource]. - * @param dataSourceFactory The Http exoplayer data source factory, by default [AkamaiTokenDataSource.Factory]. * @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. * @return [PillarboxPlayer] suited for SRG. */ @@ -75,18 +69,16 @@ object DefaultPillarbox { context: Context, seekIncrement: SeekIncrement = defaultSeekIncrement, mediaItemTrackerRepository: MediaItemTrackerProvider = DefaultMediaItemTrackerRepository(), - mediaItemSource: MediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = DefaultMediaCompositionDataSource(), - ), - dataSourceFactory: DataSource.Factory = AkamaiTokenDataSource.Factory(), - loadControl: LoadControl = PillarboxLoadControl(), + loadControl: LoadControl = DefaultLoadControl(), + mediaCompositionService: MediaCompositionService = HttpMediaCompositionService(), clock: Clock, ): PillarboxPlayer { return PillarboxPlayer( context = context, seekIncrement = seekIncrement, - dataSourceFactory = dataSourceFactory, - mediaItemSource = mediaItemSource, + mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { + addAssetLoader(SRGAssetLoader(context, mediaCompositionService)) + }, mediaItemTrackerProvider = mediaItemTrackerRepository, loadControl = loadControl, clock = clock, diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt deleted file mode 100644 index 3aa923666..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSource.kt +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -import android.net.Uri -import androidx.core.net.toUri -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import ch.srgssr.pillarbox.core.business.exception.BlockReasonException -import ch.srgssr.pillarbox.core.business.exception.DataParsingException -import ch.srgssr.pillarbox.core.business.exception.ResourceNotFoundException -import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter -import ch.srgssr.pillarbox.core.business.integrationlayer.data.Drm -import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaUrn -import ch.srgssr.pillarbox.core.business.integrationlayer.data.Resource -import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost -import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionDataSource -import ch.srgssr.pillarbox.core.business.tracker.SRGEventLoggerTracker -import ch.srgssr.pillarbox.core.business.tracker.commandersact.CommandersActTracker -import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker -import ch.srgssr.pillarbox.player.data.MediaItemSource -import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData -import ch.srgssr.pillarbox.player.extension.setTrackerData -import io.ktor.client.plugins.ClientRequestException -import io.ktor.http.URLBuilder -import io.ktor.http.appendEncodedPathSegments -import kotlinx.serialization.SerializationException -import java.io.IOException -import java.net.URL - -/** - * Load [MediaItem] playable from a [ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition] - * - * Load a [MediaItem] from it's urn set in [MediaItem.mediaId] property. - * Fill [MediaItem.mediaMetadata] from [MediaItem] if the field is not already set : - * - [MediaMetadata.title] with [Chapter.title] - * - [MediaMetadata.subtitle] with [Chapter.lead] - * - [MediaMetadata.description] with [Chapter.description] - * - * @param mediaCompositionDataSource The MediaCompositionDataSource to use to load a MediaComposition. - * @param trackerDataProvider The TrackerDataProvider to customize TrackerData. - */ -class MediaCompositionMediaItemSource( - private val mediaCompositionDataSource: MediaCompositionDataSource, - private val trackerDataProvider: TrackerDataProvider? = null -) : MediaItemSource { - private val resourceSelector = ResourceSelector() - private val imageScalingService = ImageScalingService() - - private fun fillMetaData(metadata: MediaMetadata, chapter: Chapter): MediaMetadata { - val builder = metadata.buildUpon() - metadata.title ?: builder.setTitle(chapter.title) - metadata.subtitle ?: builder.setSubtitle(chapter.lead) - metadata.description ?: builder.setDescription(chapter.description) - metadata.artworkUri ?: run { - val artworkUri = imageScalingService.getScaledImageUrl( - imageUrl = chapter.imageUrl - ).toUri() - - builder.setArtworkUri(artworkUri) - } - // Extras are forwarded to MediaController, but not involve in the equality checks - // builder.setExtras(extras) - return builder.build() - } - - private fun fillDrmConfiguration(resource: Resource): MediaItem.DrmConfiguration? { - val drm = resource.drmList.orEmpty().find { it.type == Drm.Type.WIDEVINE } - return drm?.let { - MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID) - .setLicenseUri(it.licenseUrl) - .build() - } - } - - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - require(MediaUrn.isValid(mediaItem.mediaId)) { "Invalid urn=${mediaItem.mediaId}" } - val mediaUri = mediaItem.localConfiguration?.uri - require(!MediaUrn.isValid(mediaUri.toString())) { "Uri can't be a urn" } - val result = mediaCompositionDataSource.getMediaCompositionByUrn(mediaItem.mediaId).getOrElse { - when (it) { - is ClientRequestException -> { - throw HttpResultException(it) - } - - is SerializationException -> { - throw DataParsingException(it) - } - - else -> { - throw IOException(it.message) - } - } - } - val chapter = result.mainChapter - chapter.blockReason?.let { - throw BlockReasonException(it) - } - chapter.listSegment?.firstNotNullOfOrNull { it.blockReason }?.let { - throw BlockReasonException(it) - } - - val resource = resourceSelector.selectResourceFromChapter(chapter) ?: throw ResourceNotFoundException() - var uri = Uri.parse(resource.url) - if (resource.tokenType == Resource.TokenType.AKAMAI) { - uri = appendTokenQueryToUri(uri) - } - val trackerData = mediaItem.getMediaItemTrackerData().buildUpon().apply { - trackerDataProvider?.update(this, resource, chapter, result) - putData(SRGEventLoggerTracker::class.java, null) - getComScoreData(result, chapter, resource)?.let { - putData(ComScoreTracker::class.java, it) - } - getCommandersActData(result, chapter, resource)?.let { - putData(CommandersActTracker::class.java, it) - } - }.build() - - return mediaItem.buildUpon() - .setMediaMetadata(fillMetaData(mediaItem.mediaMetadata, chapter)) - .setDrmConfiguration(fillDrmConfiguration(resource)) - .setTrackerData(trackerData) - .setUri(uri) - .build() - } - - /** - * Select a [Resource] from [Chapter.listResource] - */ - internal class ResourceSelector { - /** - * Select the first resource from chapter that is playable by the Player. - * - * @param chapter - * @return null if no compatible resource is found. - */ - @Suppress("SwallowedException") - fun selectResourceFromChapter(chapter: Chapter): Resource? { - return try { - chapter.listResource?.first { - (it.type == Resource.Type.DASH || it.type == Resource.Type.HLS || it.type == Resource.Type.PROGRESSIVE) && - (it.drmList == null || it.drmList.find { drm -> drm.type == Drm.Type.WIDEVINE } != null) - } - } catch (e: NoSuchElementException) { - null - } - } - } - - /** - * Service used to get a scaled image URL. This only works for SRG images. - * - * @param baseUrl Base URL of the service. - */ - internal class ImageScalingService( - private val baseUrl: URL = IlHost.DEFAULT - ) { - - fun getScaledImageUrl( - imageUrl: String, - ): String { - return URLBuilder(baseUrl.toString()) - .appendEncodedPathSegments("images/") - .apply { - parameters.append("imageUrl", imageUrl) - parameters.append("format", "webp") - parameters.append("width", "480") - } - .build() - .toString() - } - } - - companion object { - /** - * Token Query Param to add to trigger token request - */ - const val TOKEN_QUERY_PARAM = "withToken" - - private fun appendTokenQueryToUri(uri: Uri): Uri { - return uri.buildUpon().appendQueryParameter(TOKEN_QUERY_PARAM, "true").build() - } - - /** - * ComScore (MediaPulse) don't want to track audio. Integration layer doesn't fill analytics labels for audio content, - * but only in [chapter] and [resource]. MediaComposition will still have analytics content. - */ - private fun getComScoreData( - mediaComposition: MediaComposition, - chapter: Chapter, - resource: Resource - ): ComScoreTracker.Data? { - val comScoreData = HashMap().apply { - chapter.comScoreAnalyticsLabels?.let { - mediaComposition.comScoreAnalyticsLabels?.let { mediaComposition -> putAll(mediaComposition) } - putAll(it) - } - resource.comScoreAnalyticsLabels?.let { putAll(it) } - } - return if (comScoreData.isNotEmpty()) { - ComScoreTracker.Data(comScoreData) - } else { - null - } - } - - /** - * ComScore (MediaPulse) don't want to track audio. Integration layer doesn't fill analytics labels for audio content, - * but only in [chapter] and [resource]. MediaComposition will still have analytics content. - */ - private fun getCommandersActData( - mediaComposition: MediaComposition, - chapter: Chapter, - resource: Resource - ): CommandersActTracker.Data? { - val commandersActData = HashMap().apply { - mediaComposition.analyticsLabels?.let { mediaComposition -> putAll(mediaComposition) } - chapter.analyticsLabels?.let { putAll(it) } - resource.analyticsLabels?.let { putAll(it) } - } - return if (commandersActData.isNotEmpty()) { - // TODO : sourceId can be store inside MediaItem.metadata.extras["source_key"] - CommandersActTracker.Data(assets = commandersActData, sourceId = null) - } else { - null - } - } - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaItemUrn.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaItemUrn.kt deleted file mode 100644 index 807c4abf4..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/MediaItemUrn.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata - -/** - * Create [MediaItem] for Pillarbox from a urn. - */ -object MediaItemUrn { - /** - * Invoke - * - * @param urn The media urn to play. - * @param title The optional title to display.. - * @param subtitle The optional subtitle to display. - * @param artworkUri The artworkUri image uri. - * @return MediaItem. - */ - operator fun invoke( - urn: String, - title: String? = null, - subtitle: String? = null, - artworkUri: Uri? = null - ): MediaItem { - return MediaItem.Builder() - .setMediaId(urn) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setArtworkUri(artworkUri) - .build() - ) - .build() - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGMediaItemBuilder.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGMediaItemBuilder.kt new file mode 100644 index 000000000..24a1f0520 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGMediaItemBuilder.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business + +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost +import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector +import ch.srgssr.pillarbox.core.business.source.MimeTypeSrg +import java.net.URL + +/** + * Create a [MediaItem] that can be parsed by [PillarboxMediaSource][ch.srgssr.pillarbox.player.source.PillarboxMediaSource]. + * + * @param mediaItem Build a new [SRGMediaItemBuilder] from an existing [MediaItem]. + */ +class SRGMediaItemBuilder(mediaItem: MediaItem) { + private val mediaItemBuilder = mediaItem.buildUpon() + private var urn: String = mediaItem.mediaId + private var host: URL = IlHost.DEFAULT + private var vector: String = Vector.MOBILE + + init { + urn = mediaItem.mediaId + mediaItem.localConfiguration?.uri?.let { uri -> + val urn = uri.lastPathSegment + if (uri.toString().contains(PATH) && urn.isValidMediaUrn()) { + uri.host?.let { hostname -> host = URL(Uri.Builder().scheme(host.protocol).authority(hostname).build().toString()) } + this.urn = urn!! + uri.getQueryParameter("vector")?.let { vector = it } + } + } + } + + /** + * @param urn The SRG SSR unique identifier of a media. + */ + constructor(urn: String) : this(MediaItem.Builder().setMediaId(urn).build()) + + /** + * Set media metadata + * + * @param mediaMetadata The [MediaMetadata] to set to [MediaItem]. + * @return this for convenience + */ + fun setMediaMetadata(mediaMetadata: MediaMetadata): SRGMediaItemBuilder { + this.mediaItemBuilder.setMediaMetadata(mediaMetadata) + return this + } + + /** + * Set urn + * + * @param urn The urn that have to be a validated urn. + * @return this for convenience + */ + fun setUrn(urn: String): SRGMediaItemBuilder { + this.urn = urn + return this + } + + /** + * Set integration host + * + * @param host The host name to the integration layer server. + * @return this for convenience + */ + fun setHost(host: URL): SRGMediaItemBuilder { + this.host = host + return this + } + + /** + * Set vector + * + * @param vector The vector to forward to the integration layer. + * Should be [Vector.TV] or [Vector.MOBILE]. + * @return this for convenience + */ + fun setVector(vector: String): SRGMediaItemBuilder { + this.vector = vector + return this + } + + /** + * Build + * + * @return create a new [MediaItem]. + */ + fun build(): MediaItem { + require(urn.isValidMediaUrn()) { "Not a valid Urn!" } + mediaItemBuilder.setMediaId(urn) + mediaItemBuilder.setMimeType(MimeTypeSrg) + val uri = Uri.Builder().apply { + scheme(host.protocol) + authority(host.host) + appendEncodedPath(PATH) + appendEncodedPath(urn) + if (vector.isNotBlank()) { + appendQueryParameter(PARAM_VECTOR, vector) + } + appendQueryParameter(PARAM_ONLY_CHAPTERS, true.toString()) + }.build() + mediaItemBuilder.setUri(uri) + mediaItemBuilder.setTag(null) + return mediaItemBuilder.build() + } + + companion object { + private const val PATH = "integrationlayer/2.1/mediaComposition/byUrn/" + private const val PARAM_ONLY_CHAPTERS = "onlyChapters" + private const val PARAM_VECTOR = "vector" + } +} + +/** + * Create [MediaItem] for Pillarbox from a urn. + */ +@Deprecated("Replaced by SRGMediaItemBuilder", replaceWith = ReplaceWith("SRGMediaItemBuilder")) +object MediaItemUrn { + /** + * Invoke + * + * @param urn The media urn to play. + * @param title The optional title to display.. + * @param subtitle The optional subtitle to display. + * @param artworkUri The artworkUri image uri. + * @return MediaItem. + */ + operator fun invoke( + urn: String, + title: String? = null, + subtitle: String? = null, + artworkUri: Uri? = null + ): MediaItem = SRGMediaItemBuilder(urn) + .setMediaMetadata( + MediaMetadata.Builder().apply { + setTitle(title) + setSubtitle(subtitle) + setArtworkUri(artworkUri) + }.build() + ) + .build() +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt deleted file mode 100644 index 806b1bd4c..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/TrackerDataProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business - -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.data.Resource -import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerData - -/** - * Tracker data provider to add some data for custom tracker. - */ -interface TrackerDataProvider { - /** - * Update tracker data with given integration layer data. - * - * @param trackerData The [MediaItemTrackerData.Builder] to update. - * @param resource The selected [Resource]. - * @param chapter The selected [Chapter]. - * @param mediaComposition The loaded [MediaComposition]. - */ - fun update( - trackerData: MediaItemTrackerData.Builder, - resource: Resource, - chapter: Chapter, - mediaComposition: MediaComposition - ) -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt index ae6d7b545..c6736307e 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/akamai/AkamaiTokenDataSource.kt @@ -8,7 +8,6 @@ import android.net.Uri import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultHttpDataSource -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource import ch.srgssr.pillarbox.player.utils.DebugLogger import kotlinx.coroutines.runBlocking @@ -54,15 +53,30 @@ class AkamaiTokenDataSource private constructor( } companion object { + /** + * Token Query Param to add to trigger token request + */ + private const val TOKEN_QUERY_PARAM = "withToken" + + /** + * Append token query to uri + * + * @param uri + * @return + */ + fun appendTokenQueryToUri(uri: Uri): Uri { + return uri.buildUpon().appendQueryParameter(TOKEN_QUERY_PARAM, "true").build() + } + private fun hasNeedAkamaiToken(uri: Uri): Boolean { - return uri.getQueryParameter(MediaCompositionMediaItemSource.TOKEN_QUERY_PARAM)?.toBoolean() ?: false + return uri.getQueryParameter(TOKEN_QUERY_PARAM)?.toBoolean() ?: false } private fun removeTokenQueryParameter(uri: Uri): Uri { val queryParametersNames = uri.queryParameterNames val uriBuilder = uri.buildUpon().clearQuery().build().buildUpon() for (name in queryParametersNames) { - if (MediaCompositionMediaItemSource.TOKEN_QUERY_PARAM != name) { + if (TOKEN_QUERY_PARAM != name) { uriBuilder.appendQueryParameter(name, uri.getQueryParameter(name)) } } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt new file mode 100644 index 000000000..7e6b696a7 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingService.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.integrationlayer + +import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost +import io.ktor.http.URLBuilder +import io.ktor.http.appendEncodedPathSegments +import java.net.URL + +/** + * Service used to get a scaled image URL. This only works for SRG images. + * + * @param baseUrl Base URL of the service. + */ +internal class ImageScalingService( + private val baseUrl: URL = IlHost.DEFAULT +) { + + fun getScaledImageUrl( + imageUrl: String, + ): String { + return URLBuilder(baseUrl.toString()) + .appendEncodedPathSegments("images/") + .apply { + parameters.append("imageUrl", imageUrl) + parameters.append("format", "webp") + parameters.append("width", "480") + } + .build() + .toString() + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelector.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelector.kt new file mode 100644 index 000000000..5a1c7f59f --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelector.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.integrationlayer + +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Drm +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Resource + +/** + * Select a [Resource] from [Chapter.listResource] + */ +internal class ResourceSelector { + /** + * Select the first resource from chapter that is playable by the Player. + * + * @param chapter + * @return null if no compatible resource is found. + */ + @Suppress("SwallowedException") + fun selectResourceFromChapter(chapter: Chapter): Resource? { + return try { + chapter.listResource?.first { + (it.type == Resource.Type.DASH || it.type == Resource.Type.HLS || it.type == Resource.Type.PROGRESSIVE) && + (it.drmList == null || it.drmList.any { drm -> drm.type == Drm.Type.WIDEVINE }) + } + } catch (e: NoSuchElementException) { + null + } + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrn.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrn.kt index 8e0aa89b3..80f8bfd7d 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrn.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrn.kt @@ -4,7 +4,6 @@ */ package ch.srgssr.pillarbox.core.business.integrationlayer.data -import androidx.media3.common.MediaItem import java.util.regex.Pattern /** @@ -30,17 +29,6 @@ object MediaUrn { fun isValid(urn: String): Boolean { return pattern.matcher(urn).matches() } - - /** - * Create a [MediaItem] from given urn. - * - * @param urn The urn to create the [MediaItem]. - * @return [MediaItem] with given urn. - */ - fun createMediaItem(urn: String): MediaItem { - require(isValid(urn)) { "Invalid Urn $urn" } - return MediaItem.Builder().setMediaId(urn).build() - } } /** diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSource.kt deleted file mode 100644 index 71032dc09..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSource.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business.integrationlayer.service - -import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.http.appendEncodedPathSegments -import java.net.URL - -/** - * Default media composition data source - * - * @param httpClient Ktor HttpClient to make requests. - * @param baseUrl Base ur to make requests. - * @param vector Vector to send with the requests. [Context.getVector()] - */ -class DefaultMediaCompositionDataSource( - private val httpClient: HttpClient = DefaultHttpClient(), - private val baseUrl: URL = IlHost.DEFAULT, - private val vector: String = DEFAULT_VECTOR -) : MediaCompositionDataSource { - - override suspend fun getMediaCompositionByUrn(urn: String): Result { - return runCatching { - httpClient.get(baseUrl) { - url { - appendEncodedPathSegments("integrationlayer/2.1/mediaComposition/byUrn") - appendEncodedPathSegments(urn) - parameter("vector", vector) - parameter("onlyChapters", true) - } - }.body() - } - } - - companion object { - private const val DEFAULT_VECTOR = Vector.MOBILE - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt new file mode 100644 index 000000000..7e81427b6 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/HttpMediaCompositionService.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.integrationlayer.service + +import android.net.Uri +import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import java.net.URL + +/** + * Http MediaCompositionService. + * + * Fetch MediaComposition threw an HttpClient. + * + * @param httpClient Ktor HttpClient to make requests. + */ +class HttpMediaCompositionService( + private val httpClient: HttpClient = DefaultHttpClient(), +) : MediaCompositionService { + + override suspend fun fetchMediaComposition(uri: Uri): Result { + return runCatching { + httpClient.get(URL(uri.toString())) + .body() + } + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionDataSource.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionDataSource.kt deleted file mode 100644 index 0b0fa36b2..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionDataSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business.integrationlayer.service - -import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition - -/** - * Media composition data source interface used by [ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource] - */ -interface MediaCompositionDataSource { - /** - * Get media composition by urn - * - * @param urn Urn to get MediaComposition. - * @return Result - */ - suspend fun getMediaCompositionByUrn(urn: String): Result -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionService.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionService.kt new file mode 100644 index 000000000..99116f9a8 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/MediaCompositionService.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.integrationlayer.service + +import android.net.Uri +import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition + +/** + * Media composition service + */ +interface MediaCompositionService { + /** + * Fetch media composition + * + * @param uri The uri of the [MediaComposition] to fetch. + * @return Result + */ + suspend fun fetchMediaComposition(uri: Uri): Result +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/DefaultMediaMetaDataProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/DefaultMediaMetaDataProvider.kt new file mode 100644 index 000000000..590041a6f --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/DefaultMediaMetaDataProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.source + +import androidx.core.net.toUri +import androidx.media3.common.MediaMetadata +import ch.srgssr.pillarbox.core.business.integrationlayer.ImageScalingService +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.data.Resource + +/** + * A [SRGAssetLoader.MediaMetadataProvider] filling [MediaMetadata] from [Chapter]. + * Original MediaMetadata provided are not replaced. + */ +class DefaultMediaMetaDataProvider : SRGAssetLoader.MediaMetadataProvider { + + private val imageScalingService = ImageScalingService() + + override fun provide(mediaMetadataBuilder: MediaMetadata.Builder, resource: Resource, chapter: Chapter, mediaComposition: MediaComposition) { + val metadata = mediaMetadataBuilder.build() + metadata.title ?: mediaMetadataBuilder.setTitle(chapter.title) + metadata.subtitle ?: mediaMetadataBuilder.setSubtitle(chapter.lead) + metadata.description ?: mediaMetadataBuilder.setDescription(chapter.description) + metadata.artworkUri ?: run { + val artworkUri = imageScalingService.getScaledImageUrl( + imageUrl = chapter.imageUrl + ).toUri() + mediaMetadataBuilder.setArtworkUri(artworkUri) + } + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt new file mode 100644 index 000000000..9c0da5497 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/source/SRGAssetLoader.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.source + +import android.content.Context +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MimeTypes +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import ch.srgssr.pillarbox.core.business.HttpResultException +import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenDataSource +import ch.srgssr.pillarbox.core.business.akamai.AkamaiTokenProvider +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException +import ch.srgssr.pillarbox.core.business.exception.DataParsingException +import ch.srgssr.pillarbox.core.business.exception.ResourceNotFoundException +import ch.srgssr.pillarbox.core.business.integrationlayer.ResourceSelector +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Drm +import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Resource +import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn +import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService +import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService +import ch.srgssr.pillarbox.core.business.tracker.SRGEventLoggerTracker +import ch.srgssr.pillarbox.core.business.tracker.commandersact.CommandersActTracker +import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker +import ch.srgssr.pillarbox.player.asset.Asset +import ch.srgssr.pillarbox.player.asset.AssetLoader +import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData +import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerData +import io.ktor.client.plugins.ClientRequestException +import kotlinx.serialization.SerializationException +import java.io.IOException + +/** + * Mime Type for representing SRG SSR content + */ +const val MimeTypeSrg = "${MimeTypes.BASE_TYPE_APPLICATION}/srg-ssr" + +/** + * SRG SSR implementation of [AssetLoader]. + * + * @param context The context. + * @param mediaCompositionService The service to load a [MediaComposition]. + */ +class SRGAssetLoader( + context: Context, + private val mediaCompositionService: MediaCompositionService = HttpMediaCompositionService() +) : AssetLoader( + mediaSourceFactory = DefaultMediaSourceFactory(AkamaiTokenDataSource.Factory(AkamaiTokenProvider(), DefaultDataSource.Factory(context))) +) { + /** + * An interface to customize how [SRGAssetLoader] should fill [MediaMetadata]. + */ + fun interface MediaMetadataProvider { + /** + * Feed the available information from the [resource], [chapter], and [mediaComposition] into the provided [mediaMetadataBuilder]. + * + * @param mediaMetadataBuilder The [MediaMetadata.Builder] used to build the [MediaMetadata]. + * @param resource The [Resource] the player will play. + * @param chapter The main [Chapter] from the mediaComposition. + * @param mediaComposition The [MediaComposition] loaded from [MediaCompositionService]. + */ + fun provide( + mediaMetadataBuilder: MediaMetadata.Builder, + resource: Resource, + chapter: Chapter, + mediaComposition: MediaComposition + ) + } + + /** + * An interface to add custom tracker data. + */ + fun interface TrackerDataProvider { + /** + * Provide Tracker Data to the [Asset]. The official SRG trackers are always setup by [SRGAssetLoader]. + * + * @param trackerDataBuilder The [MediaItemTrackerData.Builder] to add trackers data. + * @param resource The [Resource] the player will play. + * @param chapter The main [Chapter] from the mediaComposition. + * @param mediaComposition The [MediaComposition] loaded from [MediaCompositionService]. + */ + fun provide( + trackerDataBuilder: MediaItemTrackerData.Builder, + resource: Resource, + chapter: Chapter, + mediaComposition: MediaComposition + ) + } + + private val resourceSelector = ResourceSelector() + + /** + * Media metadata provider to customize [Asset.mediaMetadata]. + */ + var mediaMetadataProvider: MediaMetadataProvider = DefaultMediaMetaDataProvider() + + /** + * Tracker data provider to customize [Asset.trackersData]. + */ + var trackerDataProvider: TrackerDataProvider? = null + + override fun canLoadAsset(mediaItem: MediaItem): Boolean { + val localConfiguration = mediaItem.localConfiguration ?: return false + + return localConfiguration.mimeType == MimeTypeSrg || localConfiguration.uri.lastPathSegment.isValidMediaUrn() + } + + override suspend fun loadAsset(mediaItem: MediaItem): Asset { + checkNotNull(mediaItem.localConfiguration) + val result = mediaCompositionService.fetchMediaComposition(mediaItem.localConfiguration!!.uri).getOrElse { + when (it) { + is ClientRequestException -> { + throw HttpResultException(it) + } + + is SerializationException -> { + throw DataParsingException(it) + } + + else -> { + throw IOException(it.message) + } + } + } + + val chapter = result.mainChapter + chapter.blockReason?.let { + throw BlockReasonException(it) + } + chapter.listSegment?.firstNotNullOfOrNull { it.blockReason }?.let { + throw BlockReasonException(it) + } + + val resource = resourceSelector.selectResourceFromChapter(chapter) ?: throw ResourceNotFoundException() + var uri = Uri.parse(resource.url) + if (resource.tokenType == Resource.TokenType.AKAMAI) { + uri = AkamaiTokenDataSource.appendTokenQueryToUri(uri) + } + val trackerData = mediaItem.getMediaItemTrackerData().buildUpon().apply { + trackerDataProvider?.provide(this, resource, chapter, result) + putData(SRGEventLoggerTracker::class.java) + getComScoreData(result, chapter, resource)?.let { + putData(ComScoreTracker::class.java, it) + } + getCommandersActData(result, chapter, resource)?.let { + putData(CommandersActTracker::class.java, it) + } + }.build() + + val loadingMediaItem = MediaItem.Builder() + .setDrmConfiguration(fillDrmConfiguration(resource)) + .setUri(uri) + .build() + return Asset( + mediaSource = mediaSourceFactory.createMediaSource(loadingMediaItem), + trackersData = trackerData, + mediaMetadata = mediaItem.mediaMetadata.buildUpon().apply { + mediaMetadataProvider.provide( + this, + chapter = chapter, + resource = resource, + mediaComposition = result, + ) + }.build() + ) + } + + private fun fillDrmConfiguration(resource: Resource): MediaItem.DrmConfiguration? { + val drm = resource.drmList?.find { it.type == Drm.Type.WIDEVINE } + return drm?.let { + MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID) + .setLicenseUri(it.licenseUrl) + .build() + } + } + + /** + * ComScore (MediaPulse) doesn't want to track audio. Integration layer doesn't fill analytics labels for audio content, + * but only in [chapter] and [resource]. MediaComposition will still have analytics content. + */ + private fun getComScoreData( + mediaComposition: MediaComposition, + chapter: Chapter, + resource: Resource + ): ComScoreTracker.Data? { + val comScoreData = mutableMapOf().apply { + chapter.comScoreAnalyticsLabels?.let { + mediaComposition.comScoreAnalyticsLabels?.let { mediaComposition -> putAll(mediaComposition) } + putAll(it) + } + resource.comScoreAnalyticsLabels?.let { putAll(it) } + } + return if (comScoreData.isNotEmpty()) { + ComScoreTracker.Data(comScoreData) + } else { + null + } + } + + /** + * CommandersAct doesn't want to track audio. Integration layer doesn't fill analytics labels for audio content, + * but only in [chapter] and [resource]. MediaComposition will still have analytics content. + */ + private fun getCommandersActData( + mediaComposition: MediaComposition, + chapter: Chapter, + resource: Resource + ): CommandersActTracker.Data? { + val commandersActData = mutableMapOf().apply { + mediaComposition.analyticsLabels?.let { mediaComposition -> putAll(mediaComposition) } + chapter.analyticsLabels?.let { putAll(it) } + resource.analyticsLabels?.let { putAll(it) } + } + return if (commandersActData.isNotEmpty()) { + // TODO : sourceId can be store inside MediaItem.metadata.extras["source_key"] + CommandersActTracker.Data(assets = commandersActData, sourceId = null) + } else { + null + } + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTracker.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTracker.kt index c2b0fb687..57744bb97 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTracker.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTracker.kt @@ -26,10 +26,6 @@ class SRGEventLoggerTracker : MediaItemTracker { player.removeAnalyticsListener(eventLogger) } - override fun update(data: Any) { - Log.w(TAG, "---- Update data = $data") - } - /** * Factory for a [SRGEventLoggerTracker] */ diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt index 61efa63d6..7f9242d2d 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTracker.kt @@ -49,13 +49,6 @@ class CommandersActTracker( } } - override fun update(data: Any) { - require(data is Data) - if (currentData != data) { - analyticsStreaming?.let { it.currentData = data } - } - } - override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { analyticsStreaming?.let { player.removeAnalyticsListener(it) diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTracker.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTracker.kt index 7420fb9af..6e6011134 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTracker.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTracker.kt @@ -64,13 +64,6 @@ class ComScoreTracker internal constructor( notifyEnd() } - override fun update(data: Any) { - require(data is Data) - if (latestData != data) { - setMetadata(data) - } - } - private fun setMetadata(data: Data) { DebugLogger.debug(TAG, "SetMetadata $data") val assets = ContentMetadata.Builder() diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaItemUrnTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaItemUrnTest.kt index 7768f0087..eac236171 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaItemUrnTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaItemUrnTest.kt @@ -4,44 +4,124 @@ */ package ch.srgssr.pillarbox.core.business +import android.net.Uri import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost +import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector +import ch.srgssr.pillarbox.core.business.source.MimeTypeSrg import org.junit.runner.RunWith import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull +import kotlin.test.assertNotNull @RunWith(AndroidJUnit4::class) class MediaItemUrnTest { + + @Test(expected = IllegalArgumentException::class) + fun `Check with invalid urn`() { + val urn = "urn:rts:show:3262363" + SRGMediaItemBuilder(urn).build() + } + + @Test(expected = IllegalArgumentException::class) + fun `Check with invalid mediaId`() { + SRGMediaItemBuilder(MediaItem.Builder().setMediaId("1234").build()).build() + } + + @Test + fun `Check default arguments`() { + val urn = "urn:rts:audio:3262363" + val mediaItem = SRGMediaItemBuilder(urn).build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn)), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) + assertEquals(urn, mediaItem.mediaId) + assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) + } + @Test - fun `MediaItemUrn with all parameters`() { + fun `Check set MediaMetadata`() { val urn = "urn:rts:audio:3262363" - val title = "Media title" - val subtitle = "Media subtitle" - val artworkUri = "Artwork uri".toUri() - val mediaItem = MediaItemUrn( - urn = urn, - title = title, - subtitle = subtitle, - artworkUri = artworkUri, - ) + val metadata = MediaMetadata.Builder() + .setTitle("Media title") + .setSubtitle("Media subtitle") + .setArtworkUri("Artwork uri".toUri()) + .build() + val mediaItem = SRGMediaItemBuilder(urn).apply { + setMediaMetadata(metadata) + }.build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn)), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) + assertEquals(urn, mediaItem.mediaId) + assertEquals(metadata, mediaItem.mediaMetadata) + } + @Test + fun `Check set host to Stage`() { + val urn = "urn:rts:audio:3262363" + val mediaItem = SRGMediaItemBuilder(urn) + .setHost(IlHost.STAGE) + .build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn, "il-stage.srgssr.ch")), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) assertEquals(urn, mediaItem.mediaId) - assertEquals(title, mediaItem.mediaMetadata.title) - assertEquals(subtitle, mediaItem.mediaMetadata.subtitle) - assertEquals(artworkUri, mediaItem.mediaMetadata.artworkUri) + assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) } @Test - fun `MediaItemUrn with urn only`() { + fun `Check set vector to TV`() { val urn = "urn:rts:audio:3262363" - val mediaItem = MediaItemUrn( - urn = urn, - ) + val mediaItem = SRGMediaItemBuilder(urn) + .setVector(Vector.TV) + .build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn, "il.srgssr.ch", vector = Vector.TV)), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) + assertEquals(urn, mediaItem.mediaId) + assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) + } + @Test + fun `Check uri from existing MediaItem`() { + val urn = "urn:rts:audio:3262363" + val inputMediaItem = MediaItem.Builder() + .setUri("https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/$urn?vector=${Vector.TV}") + .build() + val mediaItem = SRGMediaItemBuilder(inputMediaItem).build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn, "il-stage.srgssr.ch", vector = Vector.TV)), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) assertEquals(urn, mediaItem.mediaId) - assertNull(mediaItem.mediaMetadata.title) - assertNull(mediaItem.mediaMetadata.subtitle) - assertNull(mediaItem.mediaMetadata.artworkUri) + assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) + } + + @Test + fun `Check uri from existing MediaItem changing parameters`() { + val urn = "urn:rts:audio:3262363" + val inputMediaItem = MediaItem.Builder() + .setUri("https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/$urn?vector=${Vector.TV}") + .build() + val urn2 = "urn:rts:audio:123456" + val mediaItem = SRGMediaItemBuilder(inputMediaItem) + .setHost(IlHost.PROD) + .setVector(Vector.MOBILE) + .setUrn(urn2) + .build() + assertNotNull(mediaItem.localConfiguration) + assertEquals(Uri.parse(expectedUrl(urn2, "il.srgssr.ch", vector = Vector.MOBILE)), mediaItem.localConfiguration?.uri) + assertEquals(MimeTypeSrg, mediaItem.localConfiguration?.mimeType) + assertEquals(urn2, mediaItem.mediaId) + assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) + } + + companion object { + fun expectedUrl(urn: String, host: String = "il.srgssr.ch", vector: String = Vector.MOBILE): String { + return "https://$host/integrationlayer/2.1/mediaComposition/byUrn/$urn?vector=$vector&onlyChapters=true" + } } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSourceTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGAssetLoaderTest.kt similarity index 63% rename from pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSourceTest.kt rename to pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGAssetLoaderTest.kt index 2b4b58513..01bd87e3b 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/MediaCompositionMediaItemSourceTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGAssetLoaderTest.kt @@ -4,67 +4,77 @@ */ package ch.srgssr.pillarbox.core.business +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import ch.srgssr.pillarbox.core.business.exception.BlockReasonException import ch.srgssr.pillarbox.core.business.exception.ResourceNotFoundException +import ch.srgssr.pillarbox.core.business.integrationlayer.ImageScalingService 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.data.Resource import ch.srgssr.pillarbox.core.business.integrationlayer.data.Segment -import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService +import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.runner.RunWith +import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull @RunWith(AndroidJUnit4::class) -class MediaCompositionMediaItemSourceTest { +class SRGAssetLoaderTest { - private val mediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = DummyMediaCompositionProvider(), - ) + private val mediaCompositionService = DummyMediaCompositionProvider() + private lateinit var assetLoader: SRGAssetLoader - @Test(expected = IllegalArgumentException::class) + @BeforeTest + fun init() { + val context: Context = ApplicationProvider.getApplicationContext() + assetLoader = SRGAssetLoader(context, mediaCompositionService) + } + + @Test(expected = IllegalStateException::class) fun testNoMediaId() = runTest { - mediaItemSource.loadMediaItem(MediaItem.Builder().build()) + assetLoader.loadAsset(MediaItem.Builder().build()) } @Test(expected = IllegalArgumentException::class) fun testInvalidMediaId() = runTest { - mediaItemSource.loadMediaItem(MediaItem.Builder().setMediaId("urn:rts:show:radio:1234").build()) + assetLoader.loadAsset(SRGMediaItemBuilder("urn:rts:show:radio:1234").build()) } @Test(expected = ResourceNotFoundException::class) fun testNoResource() = runTest { - mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_NO_RESOURCES)) + assetLoader.loadAsset(SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_NO_RESOURCES).build()) } @Test(expected = ResourceNotFoundException::class) fun testNoCompatibleResource() = runTest { - mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_INCOMPATIBLE_RESOURCE)) + assetLoader.loadAsset(SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_INCOMPATIBLE_RESOURCE).build()) } @Test fun testCompatibleResource() = runTest { - val mediaItem = mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_HLS_RESOURCE)) - assertNotNull(mediaItem) + assetLoader.loadAsset(SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_HLS_RESOURCE).build()) } @Test fun testMetadata() = runTest { - val mediaItem = mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_METADATA)) - assertNotNull(mediaItem) - val metadata = mediaItem.mediaMetadata - val expected = MediaMetadata.Builder() - .setTitle("Title") - .setSubtitle("Lead") - .setDescription("Description") - .setArtworkUri(metadata.artworkUri) - .build() + val asset = assetLoader.loadAsset(SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_METADATA).build()) + val metadata = asset.mediaMetadata + val expected = + MediaMetadata.Builder() + .setTitle("Title") + .setSubtitle("Lead") + .setDescription("Description") + .setArtworkUri(metadata.artworkUri) + .build() assertEquals(expected, metadata) } @@ -75,11 +85,16 @@ class MediaCompositionMediaItemSourceTest { .setSubtitle("CustomSubtitle") .setDescription("CustomDescription") .build() - val mediaItem = mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_METADATA, input)) - assertNotNull(mediaItem) - val metadata = mediaItem.mediaMetadata + + val asset = assetLoader.loadAsset( + SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_METADATA) + .setMediaMetadata(input) + .build() + ) + + val metadata = asset.mediaMetadata val expected = input.buildUpon() - .setArtworkUri(metadata.artworkUri) + .setArtworkUri(ImageScalingService().getScaledImageUrl(DummyMediaCompositionProvider.DUMMY_IMAGE_URL).toUri()) .build() assertEquals(expected, metadata) } @@ -89,34 +104,51 @@ class MediaCompositionMediaItemSourceTest { val input = MediaMetadata.Builder() .setTitle("CustomTitle") .build() - val mediaItem = mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_METADATA, input)) - assertNotNull(mediaItem) - val metadata = mediaItem.mediaMetadata + val asset = assetLoader.loadAsset( + SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_METADATA).setMediaMetadata(input).build() + ) + val metadata = asset.mediaMetadata val expected = MediaMetadata.Builder() .setTitle("CustomTitle") .setSubtitle("Lead") .setDescription("Description") - .setArtworkUri(metadata.artworkUri) + .setArtworkUri(ImageScalingService().getScaledImageUrl(DummyMediaCompositionProvider.DUMMY_IMAGE_URL).toUri()) .build() assertEquals(expected, metadata) } + @Test + fun testCustomMetadataProvider() = runTest { + assetLoader.mediaMetadataProvider = SRGAssetLoader.MediaMetadataProvider { mediaMetadataBuilder, _, _, _ -> + mediaMetadataBuilder.setTitle("My custom title") + mediaMetadataBuilder.setSubtitle("My custom subtitle") + } + val asset = assetLoader.loadAsset(SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_METADATA).build()) + val expected = MediaMetadata.Builder() + .setTitle("My custom title") + .setSubtitle("My custom subtitle") + .build() + assertEquals(expected, asset.mediaMetadata) + } + @Test(expected = BlockReasonException::class) fun testBlockReason() = runTest { - val input = MediaMetadata.Builder().build() - mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_BLOCK_REASON, input)) + assetLoader.loadAsset( + SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_BLOCK_REASON).build() + ) } @Test(expected = BlockReasonException::class) fun testBlockedSegment() = runTest { - val input = MediaMetadata.Builder().build() - mediaItemSource.loadMediaItem(createMediaItem(DummyMediaCompositionProvider.URN_SEGMENT_BLOCK_REASON, input)) + assetLoader.loadAsset( + SRGMediaItemBuilder(DummyMediaCompositionProvider.URN_SEGMENT_BLOCK_REASON).build() + ) } - internal class DummyMediaCompositionProvider : MediaCompositionDataSource { + internal class DummyMediaCompositionProvider : MediaCompositionService { - override suspend fun getMediaCompositionByUrn(urn: String): Result { - return when (urn) { + override suspend fun fetchMediaComposition(uri: Uri): Result { + return when (val urn = uri.lastPathSegment) { URN_NO_RESOURCES -> Result.success(createMediaComposition(urn, null)) URN_EMPTY_RESOURCES -> Result.success(createMediaComposition(urn, emptyList())) URN_HLS_RESOURCE -> Result.success(createMediaComposition(urn, listOf(createResource(Resource.Type.HLS)))) @@ -142,7 +174,8 @@ class MediaCompositionMediaItemSourceTest { URN_BLOCK_REASON -> { val chapter = Chapter( urn = urn, - title = "Blocked media", blockReason = BlockReason.UNKNOWN, + title = "Blocked media", + blockReason = BlockReason.UNKNOWN, listResource = listOf(createResource(Resource.Type.HLS)), imageUrl = DUMMY_IMAGE_URL, listSegment = listOf(Segment(), Segment()) @@ -185,17 +218,4 @@ class MediaCompositionMediaItemSourceTest { } } } - - companion object { - private fun createMediaItem(urn: String): MediaItem { - return MediaItem.Builder().setMediaId(urn).build() - } - - private fun createMediaItem(urn: String, metadata: MediaMetadata): MediaItem { - return MediaItem.Builder() - .setMediaMetadata(metadata) - .setMediaId(urn) - .build() - } - } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ImageScalingServiceTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingServiceTest.kt similarity index 92% rename from pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ImageScalingServiceTest.kt rename to pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingServiceTest.kt index 2dab8f92f..ca2210834 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ImageScalingServiceTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ImageScalingServiceTest.kt @@ -2,9 +2,8 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business +package ch.srgssr.pillarbox.core.business.integrationlayer -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource.ImageScalingService import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost import java.net.URLEncoder import kotlin.test.Test diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ResourceSelectorTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelectorTest.kt similarity index 98% rename from pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ResourceSelectorTest.kt rename to pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelectorTest.kt index 1a9c54624..cfdb6c01e 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/ResourceSelectorTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/ResourceSelectorTest.kt @@ -2,7 +2,7 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business +package ch.srgssr.pillarbox.core.business.integrationlayer import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter import ch.srgssr.pillarbox.core.business.integrationlayer.data.Drm @@ -14,7 +14,7 @@ import kotlin.test.assertNull class ResourceSelectorTest { - private val resourceSelector = MediaCompositionMediaItemSource.ResourceSelector() + private val resourceSelector = ResourceSelector() @Test fun testNull() { diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrnTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrnTest.kt index 40b082acf..3648be527 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrnTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/MediaUrnTest.kt @@ -4,14 +4,9 @@ */ package ch.srgssr.pillarbox.core.business.integrationlayer.data -import androidx.media3.common.MediaItem.ClippingConfiguration -import androidx.media3.common.MediaItem.LiveConfiguration -import androidx.media3.common.MediaItem.RequestMetadata -import androidx.media3.common.MediaMetadata import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull class MediaUrnTest { @Test @@ -35,24 +30,6 @@ class MediaUrnTest { } } - @Test - fun `create media item`() { - val urn = "urn:rts:video:123345" - val mediaItem = MediaUrn.createMediaItem(urn) - - assertEquals(urn, mediaItem.mediaId) - assertEquals(ClippingConfiguration.Builder().build(), mediaItem.clippingConfiguration) - assertNull(mediaItem.localConfiguration) - assertEquals(LiveConfiguration.Builder().build(), mediaItem.liveConfiguration) - assertEquals(MediaMetadata.EMPTY, mediaItem.mediaMetadata) - assertEquals(RequestMetadata.EMPTY, mediaItem.requestMetadata) - } - - @Test(expected = IllegalArgumentException::class) - fun `create media item, invalid urn`() { - MediaUrn.createMediaItem("urn:rts:channel:tv:1234") - } - private companion object { private val urnData = mapOf( "" to false, diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSourceTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSourceTest.kt deleted file mode 100644 index 80f6c47f9..000000000 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/integrationlayer/service/DefaultMediaCompositionDataSourceTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business.integrationlayer.service - -import io.ktor.client.HttpClient -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.respondBadRequest -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class DefaultMediaCompositionDataSourceTest { - @Test - fun `get media composition by urn`() = runTest { - val vector = Vector.MOBILE - val mockEngine = MockEngine { - respond( - content = """ - { - "chapterUrn": "$URN", - "chapterList": [] - } - """.trimIndent(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), - ) - } - val mediaCompositionDataSource = DefaultMediaCompositionDataSource( - httpClient = createHttpClient(mockEngine), - baseUrl = ilHost, - vector = vector, - ) - - val mediaCompositionResult = mediaCompositionDataSource.getMediaCompositionByUrn(URN) - val requestUrl = mockEngine.requestHistory.singleOrNull()?.url?.toString() - - assertTrue(mediaCompositionResult.isSuccess) - assertEquals("${ilHost}integrationlayer/2.1/mediaComposition/byUrn/$URN?vector=$vector&onlyChapters=true", requestUrl) - - val mediaComposition = mediaCompositionResult.getOrThrow() - assertEquals(URN, mediaComposition.chapterUrn) - assertTrue(mediaComposition.listChapter.isEmpty()) - } - - @Test - fun `get media composition by urn, when request fails`() = runTest { - val vector = Vector.TV - val mockEngine = MockEngine { - respondBadRequest() - } - val mediaCompositionDataSource = DefaultMediaCompositionDataSource( - httpClient = createHttpClient(mockEngine), - baseUrl = ilHost, - vector = vector, - ) - - val mediaCompositionResult = mediaCompositionDataSource.getMediaCompositionByUrn(URN) - val requestUrl = mockEngine.requestHistory.singleOrNull()?.url?.toString() - - assertTrue(mediaCompositionResult.isFailure) - assertEquals("${ilHost}integrationlayer/2.1/mediaComposition/byUrn/$URN?vector=$vector&onlyChapters=true", requestUrl) - } - - private companion object { - private val ilHost = IlHost.TEST - private const val URN = "urn:rts:video:123345" - - private fun createHttpClient(engine: HttpClientEngine): HttpClient { - return HttpClient(engine) { - expectSuccess = true - - install(ContentNegotiation) { - json(DefaultHttpClient.jsonSerializer) - } - } - } - } -} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTrackerTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTrackerTest.kt index e44763c62..e4fc0cf37 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTrackerTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/SRGEventLoggerTrackerTest.kt @@ -20,7 +20,6 @@ class SRGEventLoggerTrackerTest { val eventLogger = SRGEventLoggerTracker.Factory().create() eventLogger.start(player, initialData = null) - eventLogger.update(data = "") eventLogger.stop(player, MediaItemTracker.StopReason.EoF, positionMs = 0L) verifySequence { diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt index def8f428e..516648b52 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt @@ -101,7 +101,7 @@ class CommandersActStreamingTest { assertEquals(C.LANGUAGE_UNDETERMINED, tcMediaEventPlay.audioTrackLanguage) assertEquals(43.seconds, tcMediaEventPlay.timeShift) assertEquals(0.25f, tcMediaEventPlay.deviceVolume) - assertEquals(0.milliseconds, tcMediaEventPlay.mediaPosition) + assertEquals(0.milliseconds.inWholeSeconds, tcMediaEventPlay.mediaPosition.inWholeSeconds) commandersActStreaming.notifyStop( position = 30.seconds, @@ -169,7 +169,7 @@ class CommandersActStreamingTest { assertEquals("en", tcMediaEvent.audioTrackLanguage) assertNull(tcMediaEvent.timeShift) assertEquals(0f, tcMediaEvent.deviceVolume) - assertEquals(0.milliseconds, tcMediaEvent.mediaPosition) + assertEquals(0.milliseconds.inWholeSeconds, tcMediaEvent.mediaPosition.inWholeSeconds) commandersActStreaming.notifyStop( position = 30.seconds, @@ -185,7 +185,7 @@ class CommandersActStreamingTest { assertEquals("en", tcMediaEvent.audioTrackLanguage) assertNull(tcMediaEvent.timeShift) assertEquals(0f, tcMediaEventStop.deviceVolume) - assertEquals(30.seconds, tcMediaEventStop.mediaPosition) + assertEquals(30.seconds.inWholeSeconds, tcMediaEventStop.mediaPosition.inWholeSeconds) } private fun createExoPlayer( diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index 940d79607..3e202521a 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -5,6 +5,7 @@ package ch.srgssr.pillarbox.core.business.tracker.commandersact import android.content.Context +import android.net.Uri import android.os.Looper import androidx.media3.common.MediaItem import androidx.media3.common.Player @@ -23,16 +24,13 @@ import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Stop import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType.Uptime import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent import ch.srgssr.pillarbox.core.business.DefaultPillarbox -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource import ch.srgssr.pillarbox.core.business.MediaItemUrn import ch.srgssr.pillarbox.core.business.integrationlayer.data.MediaComposition -import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultHttpClient -import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource -import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionDataSource +import ch.srgssr.pillarbox.core.business.integrationlayer.service.HttpMediaCompositionService +import ch.srgssr.pillarbox.core.business.integrationlayer.service.MediaCompositionService import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.core.business.tracker.comscore.ComScoreTracker -import ch.srgssr.pillarbox.player.data.MediaItemSource import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import io.mockk.Called import io.mockk.confirmVerified @@ -88,23 +86,12 @@ class CommandersActTrackerIntegrationTest { mockk(relaxed = true) } - val urnMediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = LocalMediaCompositionWithFallbackDataSource(context) - ) - val mediaItemSource = object : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return if (mediaItem.mediaId.isValidMediaUrn()) { - urnMediaItemSource.loadMediaItem(mediaItem) - } else { - mediaItem - } - } - } + val mediaCompositionWithFallbackService = LocalMediaCompositionWithFallbackService(context) player = DefaultPillarbox( context = context, mediaItemTrackerRepository = mediaItemTrackerRepository, - mediaItemSource = mediaItemSource, + mediaCompositionService = mediaCompositionWithFallbackService, clock = clock, ) } @@ -796,6 +783,7 @@ class CommandersActTrackerIntegrationTest { player.setMediaItem(MediaItemUrn(URN_VOD_SHORT)) player.prepare() player.playWhenReady = true + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) @@ -813,10 +801,10 @@ class CommandersActTrackerIntegrationTest { assertTrue(tcMediaEvents.all { it.sourceId == null }) } - private class LocalMediaCompositionWithFallbackDataSource( + private class LocalMediaCompositionWithFallbackService( context: Context, - private val fallbackDataSource: MediaCompositionDataSource = DefaultMediaCompositionDataSource(), - ) : MediaCompositionDataSource { + private val fallbackService: MediaCompositionService = HttpMediaCompositionService(), + ) : MediaCompositionService { private var mediaComposition: MediaComposition? = null init { @@ -825,13 +813,14 @@ class CommandersActTrackerIntegrationTest { mediaComposition = DefaultHttpClient.jsonSerializer.decodeFromString(json) } - override suspend fun getMediaCompositionByUrn(urn: String): Result { + override suspend fun fetchMediaComposition(uri: Uri): Result { + val urn = uri.lastPathSegment return if (urn == URN_DVR) { runCatching { requireNotNull(mediaComposition) } } else { - fallbackDataSource.getMediaCompositionByUrn(urn) + fallbackService.fetchMediaComposition(uri) } } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt index af2e2bfe1..657c7adff 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerTest.kt @@ -50,14 +50,6 @@ class CommandersActTrackerTest { ) } - @Test(expected = IllegalArgumentException::class) - fun `update() requires an instance of CommandersActTracker#Data instance for the data`() { - val commandersActs = mockk(relaxed = true) - val commandersActTracker = CommandersActTracker(commandersActs, EmptyCoroutineContext) - - commandersActTracker.update(data = "My data") - } - @Test fun `commanders act tracker`() { val player = mockk(relaxed = true) { @@ -84,18 +76,6 @@ class CommandersActTrackerTest { assertTrue(commandersActStreamingSlot.isCaptured) val commandersActStreaming = commandersActStreamingSlot.captured - val newData = CommandersActTracker.Data( - assets = mapOf( - "key1" to "value1", - ), - ) - - commandersActTracker.update( - data = newData, - ) - - assertEquals(newData, commandersActStreaming.currentData) - commandersActTracker.stop( player = player, reason = MediaItemTracker.StopReason.EoF, diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt index 3772acacf..71eb06079 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerIntegrationTest.kt @@ -16,12 +16,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import ch.srgssr.pillarbox.analytics.BuildConfig import ch.srgssr.pillarbox.core.business.DefaultPillarbox -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource import ch.srgssr.pillarbox.core.business.MediaItemUrn -import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn -import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository -import ch.srgssr.pillarbox.player.data.MediaItemSource import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import com.comscore.streaming.AssetMetadata import com.comscore.streaming.StreamingAnalytics @@ -60,23 +56,9 @@ class ComScoreTrackerIntegrationTest { ComScoreTracker(streamingAnalytics) } - val urnMediaItemSource = MediaCompositionMediaItemSource( - mediaCompositionDataSource = DefaultMediaCompositionDataSource(), - ) - val mediaItemSource = object : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return if (mediaItem.mediaId.isValidMediaUrn()) { - urnMediaItemSource.loadMediaItem(mediaItem) - } else { - mediaItem - } - } - } - player = DefaultPillarbox( context = ApplicationProvider.getApplicationContext(), mediaItemTrackerRepository = mediaItemTrackerRepository, - mediaItemSource = mediaItemSource, clock = clock, ) } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt index 6b5b8c51b..46b5a1106 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/comscore/ComScoreTrackerTest.kt @@ -146,49 +146,6 @@ class ComScoreTrackerTest { } } - @Test(expected = IllegalArgumentException::class) - fun `update() require an instance of ComScoreTracker#Data`() { - val streamingAnalytics: StreamingAnalytics = mockk(relaxed = true) - val tracker = ComScoreTracker(streamingAnalytics = streamingAnalytics) - val player = mockk(relaxed = true) - every { player.isPlaying } returns true - every { player.surfaceSize } returns Size.ZERO - every { player.playbackState } returns Player.STATE_READY - tracker.update("data") - } - - @Test - fun `update() with a different data`() { - val streamingAnalytics: StreamingAnalytics = mockk(relaxed = true) - val tracker = ComScoreTracker(streamingAnalytics = streamingAnalytics) - val player = mockk(relaxed = true) - every { player.isPlaying } returns true - every { player.surfaceSize } returns Size.ZERO - every { player.playbackState } returns Player.STATE_READY - tracker.start(player, initialData = ComScoreTracker.Data(emptyMap())) - tracker.update(ComScoreTracker.Data(mapOf("key01" to "value01"))) - - verify(exactly = 2) { - streamingAnalytics.setMetadata(any()) - } - } - - @Test - fun `update() with a same data`() { - val streamingAnalytics: StreamingAnalytics = mockk(relaxed = true) - val tracker = ComScoreTracker(streamingAnalytics = streamingAnalytics) - val player = mockk(relaxed = true) - every { player.isPlaying } returns true - every { player.surfaceSize } returns Size.ZERO - every { player.playbackState } returns Player.STATE_READY - tracker.start(player, initialData = ComScoreTracker.Data(emptyMap())) - tracker.update(ComScoreTracker.Data(emptyMap())) - - verify(exactly = 1) { - streamingAnalytics.setMetadata(any()) - } - } - @Test fun `ComScoreTracker$Factory returns an instance of ComScoreTracker`() { val mediaItemTracker = ComScoreTracker.Factory().create() diff --git a/pillarbox-demo-shared/build.gradle.kts b/pillarbox-demo-shared/build.gradle.kts index 783c35140..5c221e21b 100644 --- a/pillarbox-demo-shared/build.gradle.kts +++ b/pillarbox-demo-shared/build.gradle.kts @@ -20,13 +20,11 @@ dependencies { api(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel.ktx) api(libs.androidx.media3.common) - implementation(libs.androidx.media3.datasource) implementation(libs.androidx.media3.exoplayer) api(libs.androidx.navigation.common) api(libs.androidx.navigation.runtime) implementation(libs.androidx.paging.common) api(libs.kotlinx.coroutines.core) - implementation(libs.ktor.client.core) implementation(libs.okhttp) api(libs.srg.data) api(libs.srg.dataprovider.paging) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/DemoItem.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/DemoItem.kt index c86055f80..2d19ef488 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/DemoItem.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/DemoItem.kt @@ -11,7 +11,10 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem.DrmConfiguration import androidx.media3.common.MediaMetadata +import ch.srgssr.pillarbox.core.business.SRGMediaItemBuilder +import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost import java.io.Serializable +import java.net.URL /** * Demo item @@ -32,29 +35,40 @@ data class DemoItem( ) : Serializable { /** * Convert to a [MediaItem] - * When [uri] is a Urn, set [MediaItem.Builder.setUri] to null, - * Urn ItemSource need to have a urn defined in [MediaItem.mediaId] not its uri. + * When [uri] is an URN, the [MediaItem] is created with [SRGMediaItemBuilder]. */ - fun toMediaItem(): MediaItem { - val uri: String? = if (this.uri.startsWith("urn:")) null else this.uri - return MediaItem.Builder() - .setUri(uri) - .setMediaId(this.uri) - .setMediaMetadata( - MediaMetadata.Builder() - .setTitle(title) - .setDescription(description) - .setArtworkUri(imageUrl?.let { Uri.parse(it) }) - .build() - ) - .setDrmConfiguration( - licenseUrl?.let { - DrmConfiguration.Builder(C.WIDEVINE_UUID) - .setLicenseUri(licenseUrl) + fun toMediaItem(ilHost: URL = IlHost.PROD): MediaItem { + return if (uri.startsWith("urn:")) { + SRGMediaItemBuilder(uri) + .setHost(ilHost) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setDescription(description) + .setArtworkUri(imageUrl?.let { Uri.parse(it) }) .build() - } - ) - .build() + ) + .build() + } else { + return MediaItem.Builder() + .setUri(uri) + .setMediaId(this.uri) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setDescription(description) + .setArtworkUri(imageUrl?.let { Uri.parse(it) }) + .build() + ) + .setDrmConfiguration( + licenseUrl?.let { + DrmConfiguration.Builder(C.WIDEVINE_UUID) + .setLicenseUri(licenseUrl) + .build() + } + ) + .build() + } } companion object { diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/MixedMediaItemSource.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/MixedMediaItemSource.kt deleted file mode 100644 index 2058948d6..000000000 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/MixedMediaItemSource.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.demo.shared.data - -import androidx.media3.common.MediaItem -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource -import ch.srgssr.pillarbox.core.business.integrationlayer.data.isValidMediaUrn -import ch.srgssr.pillarbox.player.data.MediaItemSource - -/** - * Load MediaItem from [urnMediaItemSource] if the [MediaItem.mediaId] is an urn. - * - * In the demo application we are mixing url and urn. To simplify the data, we choose to store - * urn and url in the [DemoItem.uri] which provide a why to convert it to [MediaItem]. - * - * @param urnMediaItemSource item source to use with urn - */ -class MixedMediaItemSource( - private val urnMediaItemSource: MediaCompositionMediaItemSource -) : MediaItemSource { - - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return if (mediaItem.mediaId.isValidMediaUrn()) { - urnMediaItemSource.loadMediaItem(mediaItem) - } else { - mediaItem - } - } -} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt index 64d395222..f6047cd7c 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/data/Playlist.kt @@ -463,6 +463,11 @@ data class Playlist(val title: String, val items: List, val descriptio title = "Unknown URN", uri = "urn:srf:video:unknown", description = "Content that does not exist" + ), + DemoItem( + title = "Custom MediaSource", + uri = "https://custom-media.ch/fondue", + description = "Using a custom CustomMediaSource" ) ) ) diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt index cc23f3f03..940f00927 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt @@ -8,14 +8,13 @@ import android.content.Context import ch.srg.dataProvider.integrationlayer.dependencies.modules.IlServiceModule import ch.srg.dataProvider.integrationlayer.dependencies.modules.OkHttpModule import ch.srgssr.dataprovider.paging.DataProviderPaging -import ch.srgssr.pillarbox.core.business.DefaultPillarbox -import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource -import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost -import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector.getVector -import ch.srgssr.pillarbox.demo.shared.data.MixedMediaItemSource +import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader +import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository +import ch.srgssr.pillarbox.demo.shared.source.CustomAssetLoader import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.ILRepository import ch.srgssr.pillarbox.player.PillarboxPlayer +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import java.net.URL /** @@ -23,28 +22,17 @@ import java.net.URL */ object PlayerModule { - private fun provideIntegrationLayerItemSource(context: Context, ilHost: URL = IlHost.DEFAULT): MediaCompositionMediaItemSource = - MediaCompositionMediaItemSource( - mediaCompositionDataSource = DefaultMediaCompositionDataSource(vector = context.getVector(), baseUrl = ilHost), - ) - - /** - * Provide mixed item source that load Url and Urn - */ - fun provideMixedItemSource( - context: Context, - ilHost: URL = IlHost.DEFAULT - ): MixedMediaItemSource = MixedMediaItemSource( - provideIntegrationLayerItemSource(context, ilHost) - ) - /** * Provide default player that allow to play urls and urns content from the SRG */ - fun provideDefaultPlayer(context: Context, ilHost: URL = IlHost.DEFAULT): PillarboxPlayer { - return DefaultPillarbox( + fun provideDefaultPlayer(context: Context): PillarboxPlayer { + return PillarboxPlayer( context = context, - mediaItemSource = provideMixedItemSource(context, ilHost), + mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { + addAssetLoader(SRGAssetLoader(context)) + addAssetLoader(CustomAssetLoader(context)) + }, + mediaItemTrackerProvider = DefaultMediaItemTrackerRepository() ) } diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/source/CustomAssetLoader.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/source/CustomAssetLoader.kt new file mode 100644 index 000000000..51f3be656 --- /dev/null +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/source/CustomAssetLoader.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.shared.source + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ForwardingTimeline +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem +import androidx.media3.exoplayer.source.WrappingMediaSource +import ch.srgssr.pillarbox.player.asset.Asset +import ch.srgssr.pillarbox.player.asset.AssetLoader + +/** + * Custom asset loader that always load the same url and the content is not seekable. + * + * @param context The context. + */ +class CustomAssetLoader(context: Context) : AssetLoader(DefaultMediaSourceFactory(context)) { + override fun canLoadAsset(mediaItem: MediaItem): Boolean { + return mediaItem.localConfiguration?.uri?.host == "custom-media.ch" + } + + override suspend fun loadAsset(mediaItem: MediaItem): Asset { + val mediaSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri("https://swi-vod.akamaized.net/videoJson/47603186/master.m3u8")) + return Asset( + mediaMetadata = MediaMetadata.Builder() + .setTitle("${mediaItem.mediaMetadata.title}:NotSeekable") + .build(), + mediaSource = NotSeekableMediaSource(mediaSource) + ) + } +} + +/** + * A [MediaSource] that cannot be seek. + */ +private class NotSeekableMediaSource(mediaSource: MediaSource) : + WrappingMediaSource(mediaSource) { + + override fun onChildSourceInfoRefreshed(newTimeline: Timeline) { + super.onChildSourceInfoRefreshed(TimelineWithUpdatedMediaItem(NotSeekableContent(newTimeline), mediaItem)) + } + + /** + * Let's say the business required that the [NotSeekableMediaSource] cannot be seek at any time. + * @param timeline The [Timeline] to forward. + */ + private class NotSeekableContent(timeline: Timeline) : ForwardingTimeline(timeline) { + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val internalWindow = timeline.getWindow(windowIndex, window, defaultPositionProjectionUs) + internalWindow.isSeekable = false + return internalWindow + } + } +} diff --git a/pillarbox-demo/build.gradle.kts b/pillarbox-demo/build.gradle.kts index 47e73f00b..fa29d8d2d 100644 --- a/pillarbox-demo/build.gradle.kts +++ b/pillarbox-demo/build.gradle.kts @@ -76,7 +76,6 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.media3.common) - implementation(libs.androidx.media3.datasource) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.ui) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt index e3115c587..fb9f6b36e 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/SimplePlayerViewModel.kt @@ -26,11 +26,14 @@ import java.net.URL /** * Simple player view model than handle a PillarboxPlayer [player] */ -class SimplePlayerViewModel(application: Application, ilHost: URL) : AndroidViewModel(application), Player.Listener { +class SimplePlayerViewModel( + application: Application, + private val ilHost: URL +) : AndroidViewModel(application), Player.Listener { /** * Player as PillarboxPlayer */ - val player = PlayerModule.provideDefaultPlayer(application, ilHost) + val player = PlayerModule.provideDefaultPlayer(application) /** * Picture in picture enabled @@ -69,7 +72,7 @@ class SimplePlayerViewModel(application: Application, ilHost: URL) : AndroidView * @param items to play */ fun playUri(items: List) { - player.setMediaItems(items.map { it.toMediaItem() }) + player.setMediaItems(items.map { it.toMediaItem(ilHost) }) player.prepare() player.play() } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt index 7fe1047a8..183afdc27 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/layouts/StoryViewModel.kt @@ -8,7 +8,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.media3.common.C import androidx.media3.common.Player -import ch.srgssr.pillarbox.core.business.tracker.DefaultMediaItemTrackerRepository import ch.srgssr.pillarbox.demo.shared.data.Playlist import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.player.PillarboxPlayer @@ -20,28 +19,20 @@ import kotlin.math.ceil * 3 Players that interleaved DemoItems */ class StoryViewModel(application: Application) : AndroidViewModel(application) { - private val mediaItemSource = PlayerModule.provideMixedItemSource(application) - private val itemTrackerProvider = DefaultMediaItemTrackerRepository() /** * Players */ private val players = arrayOf( - PillarboxPlayer( + PlayerModule.provideDefaultPlayer( context = application, - mediaItemSource = mediaItemSource, - mediaItemTrackerProvider = itemTrackerProvider ), - PillarboxPlayer( + PlayerModule.provideDefaultPlayer( context = application, - mediaItemSource = mediaItemSource, - mediaItemTrackerProvider = itemTrackerProvider ), - PillarboxPlayer( + PlayerModule.provideDefaultPlayer( context = application, - mediaItemSource = mediaItemSource, - mediaItemTrackerProvider = itemTrackerProvider - ) + ), ) /** diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt index b6a03c1a7..3be736b47 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/SmoothSeekingShowcase.kt @@ -31,7 +31,6 @@ import androidx.media3.common.Player import ch.srgssr.pillarbox.core.business.DefaultPillarbox import ch.srgssr.pillarbox.demo.R import ch.srgssr.pillarbox.demo.shared.data.DemoItem -import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerPlaybackRow import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerTimeSlider import ch.srgssr.pillarbox.demo.ui.player.controls.rememberProgressTrackerState @@ -48,8 +47,7 @@ fun SmoothSeekingShowcase() { val context = LocalContext.current val player = remember { DefaultPillarbox( - context = context, - mediaItemSource = PlayerModule.provideMixedItemSource(context) + context = context ).apply { addMediaItem(DemoItem.UnifiedStreamingOnDemand_Dash_TrickPlay.toMediaItem()) addMediaItem(DemoItem.UnifiedStreamingOnDemandTrickplay.toMediaItem()) diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemViewModel.kt index 52322c045..fb06d430f 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemViewModel.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/UpdatableMediaItemViewModel.kt @@ -8,7 +8,9 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.ui.PlayerNotificationManager +import ch.srgssr.pillarbox.core.business.SRGMediaItemBuilder import ch.srgssr.pillarbox.demo.shared.data.DemoItem import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.player.notification.PillarboxMediaDescriptionAdapter @@ -51,16 +53,29 @@ class UpdatableMediaItemViewModel(application: Application) : AndroidViewModel(a viewModelScope.launch(Dispatchers.Main) { val currentMediaItem = player.currentMediaItem currentMediaItem?.let { - // when localConfiguration is not null, it means the urn has loaded a playable media url. - if (it.localConfiguration != null) { + if (counter < EVENT_COUNT) { updateTitle(it, "$baseTitle - $counter") - counter++ } + if (counter == EVENT_COUNT) { + switchToUrn(DemoItem.OnDemandVerticalVideo.uri) + } + counter++ } } } } + private fun switchToUrn(mediaId: String) { + val updatedMediaItem = SRGMediaItemBuilder(mediaId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Switched media") + .build() + ) + .build() + player.replaceMediaItem(player.currentMediaItemIndex, updatedMediaItem) + } + private fun updateTitle(mediaItem: MediaItem, title: String) { val updatedMediaItem = mediaItem.buildUpon() .setMediaMetadata( @@ -82,5 +97,6 @@ class UpdatableMediaItemViewModel(application: Application) : AndroidViewModel(a companion object { private const val CHANNEL_ID = "DemoChannel" private const val NOTIFICATION_ID = 456 + private const val EVENT_COUNT = 5 } } diff --git a/pillarbox-player/build.gradle.kts b/pillarbox-player/build.gradle.kts index 3bbe56fad..2de9f09c2 100644 --- a/pillarbox-player/build.gradle.kts +++ b/pillarbox-player/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { implementation(libs.androidx.media) api(libs.androidx.media3.common) implementation(libs.androidx.media3.dash) - api(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.datasource) api(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.hls) api(libs.androidx.media3.session) @@ -56,7 +56,6 @@ dependencies { testImplementation(libs.robolectric.shadows.framework) testImplementation(libs.turbine) - androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.monitor) androidTestRuntimeOnly(libs.androidx.test.runner) androidTestImplementation(libs.junit) diff --git a/pillarbox-player/docs/MediaItemTracking.md b/pillarbox-player/docs/MediaItemTracking.md index af85b8c32..d3ab76c5d 100644 --- a/pillarbox-player/docs/MediaItemTracking.md +++ b/pillarbox-player/docs/MediaItemTracking.md @@ -8,20 +8,22 @@ To enable media item tracking, you need 3 classes ## Getting started +`MediaItemTracker`s are stared and stopped when a `MediaItem` with `MediaItemTracker` changing is current state. +`PillarboxPlayer` takes care of starting and stopping `MediaItemTracker`s when needed. + ### Create a MediaItemTracker ```kotlin -class DemoMediaItemTracker : MediaItemTracker { - override fun start(player: ExoPlayer) { - player.addAnalyticsListener(eventLogger) - } +data class MyTrackingData(val data: String) - override fun stop(player: ExoPlayer) { - player.removeAnalyticsListener(eventLogger) +class DemoMediaItemTracker : MediaItemTracker { + override fun start(player: ExoPlayer, initialData: Any?) { + val data = initialData as MyTrackingData + // .... } - override fun update(data: Any) { - // Do something with data + override fun stop(player: ExoPlayer, reason: StopReason, positionMs: Long) { + // .... } class Factory : MediaItemTracker.Factory() { @@ -34,18 +36,17 @@ class DemoMediaItemTracker : MediaItemTracker { ### Append MediaItemTrackerData to MediaItem at creation -Add `MediaItemTrackerData` to a `MediaItem` only when the Uri is known. To add data for a `MediaItemTracker` you have to retrieve -a `MediaItemTrackerData` -from given `MediaItem` and put data in it. The data can be null if no data is required. +Add `MediaItemTrackerData` to a `MediaItem` only when the uri is known. To add data for a `MediaItemTracker` you have to retrieve +a `MediaItemTrackerData` from a given `MediaItem` and put data in it. The data can be `null` if no data is required. ```kotlin -class DefaultMediaItemSource : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - val trackerData = mediaItem.getMediaItemTrackerData() - trackerData.putData(DemoMediaItemTracker::class.java, MyTrackingData()) - return mediaItem.buildUpon().setTrackerData(trackerData).build() - } -} +val trackerData = MediaItemTrackerData.Builder() + .putData(DemoMediaItemTracker::class.java, MyTrackingData()) + .build() +val itemToPlay = MediaItem.Builder() + .setUri("https://sample.com/sample.mp4") + .setTrackerData(trackerData) + .build() ``` ### Create MediaItemTrackerProvider or append to MediaItemTrackerRepository @@ -62,14 +63,15 @@ If you have multiple `MediaItemTracker` you may choose `MediaItemTrackerReposito ```kotlin val mediaItemTrackerProvider = MediaItemTrackerRepository().apply { - append(DefaultMediaItemSource::class.java, DefaultMediaItemSource.Factory()) + append(DemoMediaItemTracker::class.java, DemoMediaItemTracker.Factory()) + append(DemoMediaItemTracker2::class.java, DemoMediaItemTracker2.Factory()) } ``` -### Inject into the Player +### Inject into the `PillarboxPlayer` ```kotlin -val player = PillarboxPlayer(context = context, mediaItemSource = DefaultMediaItemSource(), mediaItemTrackerProvider = DemoTrackerProvider()) +val player = PillarboxPlayer(context = context, mediaItemTrackerProvider = mediaItemTrackerProvider) // Make player ready to play content player.prepare() // Will start playback when a MediaItem is ready to play @@ -80,11 +82,9 @@ player.setMediaItem(itemToPlay) ### Toggle tracking -You can disable or enable tracking during execution with `player.trackingEnable`. +You can disable or enable tracking during execution with `player.trackingEnable`. It will start or stop all `MediaItemTracker` provided. ```kotlin -val player = PillarboxPlayer(context = context, mediaItemSource = DefaultMediaItemSource(), mediaItemTrackerProvider = DemoTrackerProvider()) -// Disable media stream tracking player.trackingEnabled = false ``` diff --git a/pillarbox-player/docs/README.md b/pillarbox-player/docs/README.md index 4597c5abd..eca2c2dbd 100644 --- a/pillarbox-player/docs/README.md +++ b/pillarbox-player/docs/README.md @@ -25,36 +25,26 @@ More information can be found on the [top level README](../docs/README.md) ## Getting started -### Create a MediaItemSource - -`MediaItemSource` create a `MediaItem` with all media information needed by 'PillarboxPlayer'. More information about MediaItem creation can be -found [here](https://exoplayer.dev/media-items.html) +### Create a `MediaItem` ```kotlin -/** - * Goal : Get a MediaItem with a mediaUri set. - */ -class DefaultMediaItemSource : MediaItemSource { - /** - * Suspend function to allow network call to fill MediaItem metadata and mediaUri if needed. - */ - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return mediaItem - } -} +val mediaItem = MediaItem.fromUri(videoUri) ``` -### Create a PillarboxPlayer +### Create a `PillarboxPlayer` ```kotlin -val player = PillarboxPlayer(context = context, mediaItemSource = DefaultMediaItemSource()) +val player = PillarboxPlayer(context = context) // Make player ready to play content player.prepare() // Will start playback when a MediaItem is ready to play player.play() ``` -### Start playing a video +### Start playing a content + +Create a `MediaItem` with all media information needed by `PillarboxPlayer` as you would do with ExoPlayer. +More information about `MediaItem` creation can be found [here](https://developer.android.com/media/media3/exoplayer/media-items) ```kotlin val itemToPlay = MediaItem.fromUri("https://sample.com/sample.mp4") @@ -215,8 +205,8 @@ MediaController.release(listenableFuture) As `PillarboxPlayer` extending an _Exoplayer_ `Player`, all documentation related to Exoplayer is valid for Pillarbox. -- [HelloWorld](https://exoplayer.dev/hello-world.html) -- [Player Events](https://exoplayer.dev/listening-to-player-events.html) -- [MediaItem](https://exoplayer.dev/media-items.html) -- [Playlist](https://exoplayer.dev/playlists.html) -- [Track Selection](https://exoplayer.dev/track-selection.html) +- [HelloWorld](https://developer.android.com/media/media3/exoplayer/hello-world.html) +- [Player Events](https://developer.android.com/media/media3/exoplayer/listening-to-player-events) +- [MediaItem](https://developer.android.com/media/media3/exoplayer/media-items) +- [Playlist](https://developer.android.com/media/media3/exoplayer/playlists) +- [Track Selection](https://developer.android.com/media/media3/exoplayer/track-selection) diff --git a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/IsPlayingAllTypeOfContentTest.kt b/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/IsPlayingAllTypeOfContentTest.kt index 33335f0cc..5d555a11f 100644 --- a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/IsPlayingAllTypeOfContentTest.kt +++ b/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/IsPlayingAllTypeOfContentTest.kt @@ -5,12 +5,12 @@ package ch.srgssr.pillarbox.player import android.net.Uri +import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.ConditionVariable import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import ch.srgssr.pillarbox.player.utils.ContentUrls -import ch.srgssr.pillarbox.player.utils.UniqueMediaItemSource import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -27,24 +27,6 @@ class IsPlayingAllTypeOfContentTest { @Parameterized.Parameter lateinit var urlToTest: String - companion object { - @Parameterized.Parameters(name = "{0}") - @JvmStatic - fun parameters(): Iterable { - return listOf( - ContentUrls.VOD_MP4, - ContentUrls.VOD_HLS, - ContentUrls.AOD_MP3, - ContentUrls.VOD_DASH_H264, - ContentUrls.VOD_DASH_H265, - ContentUrls.LIVE_HLS, - ContentUrls.LIVE_DVR_HLS, - ContentUrls.AUDIO_LIVE_DVR_HLS, - ContentUrls.AUDIO_LIVE_MP3 - ) - } - } - @Test fun isPlayingTest() { // Context of the app under test. @@ -53,10 +35,10 @@ class IsPlayingAllTypeOfContentTest { val waitIsPlaying = WaitIsPlaying() getInstrumentation().runOnMainSync { val player = PillarboxPlayer( - appContext, UniqueMediaItemSource(urlToTest) + appContext ) atomicPlayer.set(player) - player.addMediaItem(UniqueMediaItemSource.createMediaItem()) + player.addMediaItem(MediaItem.fromUri(urlToTest)) player.addListener(waitIsPlaying) player.prepare() player.play() @@ -99,4 +81,22 @@ class IsPlayingAllTypeOfContentTest { isPlaying.block() } } + + companion object { + @Parameterized.Parameters(name = "{0}") + @JvmStatic + fun parameters(): Iterable { + return listOf( + ContentUrls.VOD_MP4, + ContentUrls.VOD_HLS, + ContentUrls.AOD_MP3, + ContentUrls.VOD_DASH_H264, + ContentUrls.VOD_DASH_H265, + ContentUrls.LIVE_HLS, + ContentUrls.LIVE_DVR_HLS, + ContentUrls.AUDIO_LIVE_DVR_HLS, + ContentUrls.AUDIO_LIVE_MP3 + ) + } + } } diff --git a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/MediaItemSourceTest.kt b/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/MediaItemSourceTest.kt deleted file mode 100644 index 14eb5446f..000000000 --- a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/MediaItemSourceTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player - -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.ConditionVariable -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import ch.srgssr.pillarbox.player.data.MediaItemSource -import ch.srgssr.pillarbox.player.utils.ContentUrls -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import java.util.concurrent.atomic.AtomicReference - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class MediaItemSourceTest { - - @Test - fun mediaItemFromMediaItemSource() { - // Context of the app under test. - val appContext = getInstrumentation().targetContext - val customTag = "TagForTest" - val url = ContentUrls.VOD_MP4 - val atomicPlayer = AtomicReference() - val waitForReady = WaitReadyListener() - getInstrumentation().runOnMainSync { - val player = PillarboxPlayer( - appContext, - object : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return mediaItem.buildUpon() - .setUri(url) - .setTag(customTag) - .build() - } - } - ) - atomicPlayer.set(player) - player.addMediaItem(MediaItem.Builder().setMediaId("DummyId").build()) - player.addListener(waitForReady) - player.prepare() - player.play() - } - - waitForReady.block() - - getInstrumentation().runOnMainSync { - val player = atomicPlayer.get() - Assert.assertEquals(Player.STATE_READY, player.playbackState) - Assert.assertNotNull(player.currentMediaItem) - Assert.assertEquals(player.currentMediaItem?.localConfiguration?.uri, Uri.parse(url)) - Assert.assertEquals(customTag, player.currentMediaItem?.localConfiguration?.tag) - player.release() - } - } - - private class WaitReadyListener : Player.Listener { - private val playbackEnded = ConditionVariable() - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_READY) { - playbackEnded.open() - } - } - - fun block() { - playbackEnded.block() - } - } -} diff --git a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/utils/UniqueMediaItemSource.kt b/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/utils/UniqueMediaItemSource.kt deleted file mode 100644 index 63fa023de..000000000 --- a/pillarbox-player/src/androidTest/java/ch/srgssr/pillarbox/player/utils/UniqueMediaItemSource.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.utils - -import androidx.media3.common.MediaItem -import ch.srgssr.pillarbox.player.data.MediaItemSource - -class UniqueMediaItemSource(private val url: String) : MediaItemSource { - - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return mediaItem.buildUpon().setUri(url).build() - } - - companion object { - fun createMediaItem(mediaId: String = "dummyId"): MediaItem { - return MediaItem.Builder().setMediaId(mediaId).build() - } - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt index 07cfcceff..fa592d778 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxPlayer.kt @@ -13,16 +13,12 @@ import androidx.media3.common.Player import androidx.media3.common.Timeline.Window import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.util.Clock -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter import androidx.media3.exoplayer.util.EventLogger -import ch.srgssr.pillarbox.player.data.MediaItemSource import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed import ch.srgssr.pillarbox.player.extension.setPreferredAudioRoleFlagsToAccessibilityManagerSettings import ch.srgssr.pillarbox.player.extension.setSeekIncrements @@ -83,15 +79,13 @@ class PillarboxPlayer internal constructor( constructor( context: Context, - mediaItemSource: MediaItemSource, - dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), seekIncrement: SeekIncrement = SeekIncrement() ) : this( context = context, - mediaItemSource = mediaItemSource, - dataSourceFactory = dataSourceFactory, + mediaSourceFactory = mediaSourceFactory, loadControl = loadControl, mediaItemTrackerProvider = mediaItemTrackerProvider, seekIncrement = seekIncrement, @@ -101,8 +95,7 @@ class PillarboxPlayer internal constructor( @VisibleForTesting constructor( context: Context, - mediaItemSource: MediaItemSource, - dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), + mediaSourceFactory: PillarboxMediaSourceFactory = PillarboxMediaSourceFactory(context), loadControl: LoadControl = PillarboxLoadControl(), mediaItemTrackerProvider: MediaItemTrackerProvider = MediaItemTrackerRepository(), seekIncrement: SeekIncrement = SeekIncrement(), @@ -119,12 +112,7 @@ class PillarboxPlayer internal constructor( ) .setBandwidthMeter(DefaultBandwidthMeter.getSingletonInstance(context)) .setLoadControl(loadControl) - .setMediaSourceFactory( - PillarboxMediaSourceFactory( - mediaItemSource = mediaItemSource, - defaultMediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) - ) - ) + .setMediaSourceFactory(mediaSourceFactory) .setTrackSelector( DefaultTrackSelector( context, @@ -152,6 +140,54 @@ class PillarboxPlayer internal constructor( } } + override fun setMediaItem(mediaItem: MediaItem) { + exoPlayer.setMediaItem(mediaItem.clearTag()) + } + + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) { + exoPlayer.setMediaItem(mediaItem.clearTag(), resetPosition) + } + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) { + exoPlayer.setMediaItem(mediaItem.clearTag(), startPositionMs) + } + + override fun setMediaItems(mediaItems: List) { + exoPlayer.setMediaItems(mediaItems.map { it.clearTag() }) + } + + override fun setMediaItems(mediaItems: List, resetPosition: Boolean) { + exoPlayer.setMediaItems(mediaItems.map { it.clearTag() }, resetPosition) + } + + override fun setMediaItems(mediaItems: List, startIndex: Int, startPositionMs: Long) { + exoPlayer.setMediaItems(mediaItems.map { it.clearTag() }, startIndex, startPositionMs) + } + + override fun addMediaItem(mediaItem: MediaItem) { + exoPlayer.addMediaItem(mediaItem.clearTag()) + } + + override fun addMediaItem(index: Int, mediaItem: MediaItem) { + exoPlayer.addMediaItem(index, mediaItem.clearTag()) + } + + override fun addMediaItems(mediaItems: List) { + exoPlayer.addMediaItems(mediaItems.map { it.clearTag() }) + } + + override fun addMediaItems(index: Int, mediaItems: List) { + exoPlayer.addMediaItems(index, mediaItems.map { it.clearTag() }) + } + + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) { + exoPlayer.replaceMediaItem(index, mediaItem.clearTag()) + } + + override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: List) { + exoPlayer.replaceMediaItems(fromIndex, toIndex, mediaItems.map { it.clearTag() }) + } + override fun seekTo(positionMs: Long) { if (!smoothSeekingEnabled) { exoPlayer.seekTo(positionMs) @@ -360,3 +396,5 @@ internal fun Window.isAtDefaultPosition(positionMs: Long): Boolean { } private const val NormalSpeed = 1.0f + +private fun MediaItem.clearTag() = this.buildUpon().setTag(null).build() diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/Asset.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/Asset.kt new file mode 100644 index 000000000..26dc0201d --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/Asset.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.asset + +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.source.MediaSource +import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerData + +/** + * Assets + * + * @property mediaSource The [MediaSource] used by the player to play something. + * @property trackersData The [MediaItemTrackerData] to use. + * @property mediaMetadata The [MediaMetadata] to set to the player media item. + */ +data class Asset( + val mediaSource: MediaSource, + val trackersData: MediaItemTrackerData = MediaItemTrackerData.EMPTY, + val mediaMetadata: MediaMetadata = MediaMetadata.EMPTY, +) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/AssetLoader.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/AssetLoader.kt new file mode 100644 index 000000000..97bd4c55b --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/AssetLoader.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.asset + +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.source.MediaSource + +/** + * Asset loader + * + * @property mediaSourceFactory + * @constructor Create empty Asset loader + */ +abstract class AssetLoader(var mediaSourceFactory: MediaSource.Factory) { + /** + * Can load asset + * + * @param mediaItem The input [MediaItem]. + * @return true if this AssetLoader can load an Asset from the mediaItem. + */ + abstract fun canLoadAsset(mediaItem: MediaItem): Boolean + + /** + * Load asset + * + * @param mediaItem The input [MediaItem] + * @return a [Asset]. + */ + abstract suspend fun loadAsset(mediaItem: MediaItem): Asset +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/UrlAssetLoader.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/UrlAssetLoader.kt new file mode 100644 index 000000000..a88974f02 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/asset/UrlAssetLoader.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.asset + +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import ch.srgssr.pillarbox.player.asset.UrlAssetLoader.TrackerDataProvider +import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerData + +/** + * AssetLoader to load Asset from an stream url. + * + * @param defaultMediaSourceFactory The [DefaultMediaSourceFactory] to create a MediaSource for the player. + */ +class UrlAssetLoader( + defaultMediaSourceFactory: DefaultMediaSourceFactory, +) : AssetLoader(defaultMediaSourceFactory) { + /** + * The [TrackerDataProvider] to customize tracker data. + */ + var trackerDataProvider: TrackerDataProvider = DEFAULT_TRACKER_DATA_LOADER + + /** + * Tracker data loader + * + * @constructor Create empty Tracker data loader + */ + fun interface TrackerDataProvider { + /** + * Provide Tracker Data to the [MediaItem]. + * + * @param mediaItem The input [MediaItem] of the [UrlAssetLoader.loadAsset]. + * @param trackerDataBuilder The [MediaItemTrackerData.Builder] to add tracker data. + */ + suspend fun provide(mediaItem: MediaItem, trackerDataBuilder: MediaItemTrackerData.Builder) + } + + override fun canLoadAsset(mediaItem: MediaItem): Boolean { + return mediaItem.localConfiguration != null + } + + override suspend fun loadAsset(mediaItem: MediaItem): Asset { + val mediaSource = mediaSourceFactory.createMediaSource(mediaItem) + val trackerData = MediaItemTrackerData.Builder().apply { + trackerDataProvider.provide(mediaItem, this) + }.build() + return Asset(mediaSource = mediaSource, mediaMetadata = mediaItem.mediaMetadata, trackersData = trackerData) + } + + companion object { + private val DEFAULT_TRACKER_DATA_LOADER = TrackerDataProvider { _, _ -> } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/data/MediaItemSource.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/data/MediaItemSource.kt deleted file mode 100644 index 9218025ca..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/data/MediaItemSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.data - -import androidx.media3.common.MediaItem - -/** - * Media item source load MediaItem with a suspend function. - */ -interface MediaItemSource { - /** - * Load media item from [mediaItem] in a suspend function - * - * @param mediaItem - * @return MediaItem buildUpon [mediaItem] - */ - suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt index 598801b36..0359211b4 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSource.kt @@ -8,29 +8,30 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Timeline import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.source.CompositeMediaSource +import androidx.media3.exoplayer.source.ForwardingTimeline import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.TimelineWithUpdatedMediaItem import androidx.media3.exoplayer.upstream.Allocator -import ch.srgssr.pillarbox.player.data.MediaItemSource -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource.PillarboxTimeline.Companion.LIVE_DVR_MIN_DURATION_MS +import ch.srgssr.pillarbox.player.asset.AssetLoader +import ch.srgssr.pillarbox.player.extension.setTrackerData import ch.srgssr.pillarbox.player.utils.DebugLogger import kotlinx.coroutines.runBlocking /** - * Pillarbox media source load a MediaItem from [mediaItem] with [mediaItemSource]. - * It use [mediaSourceFactory] to create the real underlying MediaSource playable for Exoplayer. + * Pillarbox media source * - * @param mediaItem input mediaItem - * @param mediaItemSource load asynchronously a MediaItem - * @param mediaSourceFactory create MediaSource from a MediaItem + * @param mediaItem The [MediaItem] to used for the assetLoader. + * @param assetLoader The [AssetLoader] to used to load the source. + * @param minLiveDvrDurationMs Minimal duration in milliseconds to consider a live with seek capabilities. * @constructor Create empty Pillarbox media source */ -class PillarboxMediaSource( +class PillarboxMediaSource internal constructor( private var mediaItem: MediaItem, - private val mediaItemSource: MediaItemSource, - private val mediaSourceFactory: MediaSource.Factory -) : CompositeMediaSource() { - private var loadedMediaSource: MediaSource? = null + private val assetLoader: AssetLoader, + private val minLiveDvrDurationMs: Long, +) : CompositeMediaSource() { + private lateinit var mediaSource: MediaSource private var pendingError: Throwable? = null @Suppress("TooGenericExceptionCaught") @@ -41,19 +42,24 @@ class PillarboxMediaSource( // We have to use runBlocking to execute code in the same thread as prepareSourceInternal due to DRM. runBlocking { try { - val loadedItem = mediaItemSource.loadMediaItem(mediaItem) - mediaItem = loadedItem - loadedMediaSource = mediaSourceFactory.createMediaSource(loadedItem) - loadedMediaSource?.let { - DebugLogger.debug(TAG, "prepare child source loaded mediaId = ${loadedItem.mediaId}") - prepareChildSource(loadedItem.mediaId, it) - } + val asset = assetLoader.loadAsset(mediaItem) + DebugLogger.debug(TAG, "Asset(${mediaItem.localConfiguration?.uri}) : ${asset.trackersData}") + mediaSource = asset.mediaSource + mediaItem = mediaItem.buildUpon() + .setMediaMetadata(asset.mediaMetadata) + .setTrackerData(asset.trackersData) + .build() + prepareChildSource(Unit, mediaSource) } catch (e: Exception) { handleException(e) } } } + override fun onChildSourceInfoRefreshed(childSourceId: Unit?, mediaSource: MediaSource, newTimeline: Timeline) { + refreshSourceInfo(PillarboxTimeline(minLiveDvrDurationMs, TimelineWithUpdatedMediaItem(newTimeline, getMediaItem()))) + } + /** * Can update media item * @@ -63,16 +69,16 @@ class PillarboxMediaSource( * @return true if the media can be update without reloading the media source. */ override fun canUpdateMediaItem(mediaItem: MediaItem): Boolean { - val currentItemWithoutTrackerData = this.mediaItem.buildUpon().setTag(null).build() - val mediaItemWithoutTrackerData = mediaItem.buildUpon().setTag(null).build() - return !( - currentItemWithoutTrackerData.mediaId != mediaItemWithoutTrackerData.mediaId || - currentItemWithoutTrackerData.localConfiguration != mediaItemWithoutTrackerData.localConfiguration - ) + val currentItemWithoutTag = this.mediaItem.buildUpon().setTag(null).build() + val mediaItemWithoutTag = mediaItem.buildUpon().setTag(null).build() + return currentItemWithoutTag.mediaId == mediaItemWithoutTag.mediaId && + currentItemWithoutTag.localConfiguration == mediaItemWithoutTag.localConfiguration } override fun updateMediaItem(mediaItem: MediaItem) { - this.mediaItem = mediaItem + this.mediaItem = this.mediaItem.buildUpon() + .setMediaMetadata(mediaItem.mediaMetadata) + .build() } @Suppress("TooGenericExceptionCaught") @@ -95,7 +101,6 @@ class PillarboxMediaSource( super.releaseSourceInternal() DebugLogger.debug(TAG, "releaseSourceInternal") pendingError = null - loadedMediaSource = null } override fun getMediaItem(): MediaItem { @@ -109,21 +114,12 @@ class PillarboxMediaSource( startPositionUs: Long ): MediaPeriod { DebugLogger.debug(TAG, "createPeriod: $id") - return loadedMediaSource!!.createPeriod(id, allocator, startPositionUs) + return mediaSource.createPeriod(id, allocator, startPositionUs) } override fun releasePeriod(mediaPeriod: MediaPeriod) { DebugLogger.debug(TAG, "releasePeriod: $mediaPeriod") - loadedMediaSource?.releasePeriod(mediaPeriod) - } - - override fun onChildSourceInfoRefreshed( - id: String?, - mediaSource: MediaSource, - timeline: Timeline - ) { - DebugLogger.debug(TAG, "onChildSourceInfoRefreshed: $id") - refreshSourceInfo(PillarboxTimeline(timeline)) + mediaSource.releasePeriod(mediaPeriod) } private fun handleException(exception: Throwable) { @@ -132,41 +128,18 @@ class PillarboxMediaSource( } /** - * Pillarbox timeline wrap the underlying Timeline to suite Pillarbox needs. - * - Live stream with a window duration <= [LIVE_DVR_MIN_DURATION_MS] are not seekable. + * Pillarbox timeline wrap the underlying Timeline to suite SRGSSR needs. + * - Live stream with a window duration <= [minLiveDvrDurationMs] cannot seek. */ - private class PillarboxTimeline(private val timeline: Timeline) : Timeline() { - override fun getWindowCount(): Int { - return timeline.windowCount - } + private class PillarboxTimeline(val minLiveDvrDurationMs: Long, timeline: Timeline) : ForwardingTimeline(timeline) { override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { val internalWindow = timeline.getWindow(windowIndex, window, defaultPositionProjectionUs) if (internalWindow.isLive()) { - internalWindow.isSeekable = internalWindow.durationMs >= LIVE_DVR_MIN_DURATION_MS + internalWindow.isSeekable = internalWindow.durationMs >= minLiveDvrDurationMs } return internalWindow } - - override fun getPeriodCount(): Int { - return timeline.periodCount - } - - override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { - return timeline.getPeriod(periodIndex, period, setIds) - } - - override fun getIndexOfPeriod(uid: Any): Int { - return timeline.getIndexOfPeriod(uid) - } - - override fun getUidOfPeriod(periodIndex: Int): Any { - return timeline.getUidOfPeriod(periodIndex) - } - - companion object { - private const val LIVE_DVR_MIN_DURATION_MS = 60000L // 60s - } } companion object { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt index f201b1eb8..f34b46d83 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/source/PillarboxMediaSourceFactory.kt @@ -1,38 +1,84 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ package ch.srgssr.pillarbox.player.source +import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy -import ch.srgssr.pillarbox.player.data.MediaItemSource +import ch.srgssr.pillarbox.player.asset.AssetLoader +import ch.srgssr.pillarbox.player.asset.UrlAssetLoader /** - * Pillarbox media source factory create a new PillarboxMediaSource from a MediaItem + * Pillarbox media source factory create a new [PillarboxMediaSource] from a [MediaItem]. + * It selects the first [AssetLoader] to use by checking if [AssetLoader.canLoadAsset]. * - * @param defaultMediaSourceFactory default MediaSourceFactory to load real underlying MediaSource - * @param mediaItemSource for loading asynchronously a MediaItem - * @constructor Create empty Pillarbox media source factory + * @param context to create the [defaultAssetLoader]. */ -class PillarboxMediaSourceFactory( - private val defaultMediaSourceFactory: DefaultMediaSourceFactory, - private val mediaItemSource: MediaItemSource -) : MediaSource.Factory { - override fun setDrmSessionManagerProvider( - drmSessionManagerProvider: DrmSessionManagerProvider - ): MediaSource.Factory { - return defaultMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) +class PillarboxMediaSourceFactory(context: Context) : MediaSource.Factory { + /** + * Default asset loader used when no other AssetLoader has been found. + */ + val defaultAssetLoader = UrlAssetLoader(DefaultMediaSourceFactory(context)) + + /** + * Minimal duration in milliseconds to consider a live with seek capabilities. + */ + var minLiveDvrDurationMs = LIVE_DVR_MIN_DURATION_MS + private val listAssetLoader = mutableListOf() + + /** + * Add asset loader + * + * @param index index at which the specified element is to be inserted element – element to be inserted + * @param assetLoader [AssetLoader] to insert. + */ + fun addAssetLoader(index: Int, assetLoader: AssetLoader) { + check(assetLoader !is UrlAssetLoader) { "Already in the factory by default" } + listAssetLoader.add(index, assetLoader) + } + + /** + * Add asset loader + * + * @param assetLoader [AssetLoader] to insert. + */ + fun addAssetLoader(assetLoader: AssetLoader) { + check(assetLoader !is UrlAssetLoader) { "Already in the factory by default" } + listAssetLoader.add(assetLoader) + } + + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + for (assetLoader in listAssetLoader) { + assetLoader.mediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) + } + defaultAssetLoader.mediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) + return this } override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { - return defaultMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + for (assetLoader in listAssetLoader) { + assetLoader.mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + } + defaultAssetLoader.mediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) + return this } override fun getSupportedTypes(): IntArray { - return defaultMediaSourceFactory.supportedTypes + return defaultAssetLoader.mediaSourceFactory.supportedTypes } override fun createMediaSource(mediaItem: MediaItem): MediaSource { - return PillarboxMediaSource(mediaItem, mediaItemSource, defaultMediaSourceFactory) + checkNotNull(mediaItem.localConfiguration) + val assetLoader = listAssetLoader.firstOrNull { it.canLoadAsset(mediaItem) } ?: defaultAssetLoader + return PillarboxMediaSource(mediaItem = mediaItem, assetLoader = assetLoader, minLiveDvrDurationMs = minLiveDvrDurationMs) + } + + companion object { + private const val LIVE_DVR_MIN_DURATION_MS = 60_000L } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt index fab7a5c0d..03b3604bc 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTracker.kt @@ -11,6 +11,9 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull +import ch.srgssr.pillarbox.player.utils.DebugLogger +import ch.srgssr.pillarbox.player.utils.StringUtil +import kotlin.time.Duration.Companion.milliseconds /** * Current media item tracker @@ -45,64 +48,37 @@ internal class CurrentMediaItemTracker internal constructor( set(value) { if (field == value) return field = value - setMediaItem(player.currentMediaItem) + if (!field) { + stopSession(MediaItemTracker.StopReason.Stop) + } else { + player.currentMediaItem?.let { setMediaItem(it) } + } } init { player.addAnalyticsListener(this) - player.currentMediaItem?.let { startNewSession(it) } + player.currentMediaItem?.let { setMediaItem(it) } } - /** - * Set media item if has not tracking data, set to null - */ - private fun setMediaItem(mediaItem: MediaItem?) { - if (enabled && mediaItem != null && mediaItem.canHaveTrackingSession()) { - if (!areEqual(currentMediaItem, mediaItem)) { - currentItemChange(currentMediaItem, mediaItem) - currentMediaItem = mediaItem - } - } else { - currentMediaItem?.let { - stopSession(MediaItemTracker.StopReason.Stop, player.currentPosition) + private fun setMediaItem(mediaItem: MediaItem) { + if (!areEqual(mediaItem, currentMediaItem)) { + stopSession(MediaItemTracker.StopReason.Stop) + currentMediaItem = mediaItem + if (mediaItem.canHaveTrackingSession()) { + startNewSession(mediaItem) } - } - } - - private fun currentItemChange(lastMediaItem: MediaItem?, newMediaItem: MediaItem) { - if (lastMediaItem == null) { - startNewSession(newMediaItem) return } - if (lastMediaItem.mediaId == newMediaItem.mediaId || lastMediaItem.getMediaItemTrackerData() != newMediaItem.getMediaItemTrackerData()) { - maybeUpdateData(lastMediaItem, newMediaItem) - } else { - stopSession(MediaItemTracker.StopReason.Stop) - startNewSession(newMediaItem) - } - } - - /** - * Maybe update data - * - * Don't start or stop if new tracker data is added. Only update existing trackers with new data. - */ - private fun maybeUpdateData(lastMediaItem: MediaItem, newMediaItem: MediaItem) { - trackers?.let { - val lastTrackerData = lastMediaItem.getMediaItemTrackerData() - val newTrackerData = newMediaItem.getMediaItemTrackerData() - for (tracker in it) { - val newData = newTrackerData.getData(tracker) ?: continue - val oldData = lastTrackerData.getData(tracker) - if (newData != oldData) { - tracker.update(newData) - } - } + if (mediaItem.canHaveTrackingSession() && currentMediaItem?.getMediaItemTrackerDataOrNull() == null) { + startNewSession(mediaItem) + // Update current media item with tracker data + this.currentMediaItem = mediaItem } } private fun stopSession(stopReason: MediaItemTracker.StopReason, positionMs: Long = player.currentPosition) { trackers?.let { + DebugLogger.info(TAG, "stop trackers $stopReason @${positionMs.milliseconds}") for (tracker in it) { tracker.stop(player, stopReason, positionMs) } @@ -114,6 +90,8 @@ internal class CurrentMediaItemTracker internal constructor( private fun startNewSession(mediaItem: MediaItem) { if (!enabled) return require(trackers == null) + DebugLogger.info(TAG, "start new session for ${mediaItem.prettyString()}") + mediaItem.getMediaItemTrackerData().also { trackerData -> val trackers = MediaItemTrackerList() // Create each tracker for this new MediaItem @@ -127,26 +105,45 @@ internal class CurrentMediaItemTracker internal constructor( } override fun onTimelineChanged(eventTime: AnalyticsListener.EventTime, reason: Int) { - setMediaItem(player.currentMediaItem) + DebugLogger.debug( + TAG, + "onTimelineChanged ${StringUtil.timelineChangeReasonString(reason)} ${player.currentMediaItem.prettyString()}" + ) + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + player.currentMediaItem?.let { setMediaItem(it) } + } } override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, playbackState: Int) { + DebugLogger.debug( + TAG, + "onPlaybackStateChanged ${StringUtil.playerStateString(playbackState)} ${player.currentMediaItem.prettyString()}" + ) when (playbackState) { Player.STATE_ENDED -> stopSession(MediaItemTracker.StopReason.EoF) Player.STATE_IDLE -> stopSession(MediaItemTracker.StopReason.Stop) - Player.STATE_READY -> if (currentMediaItem == null) setMediaItem(player.currentMediaItem) + Player.STATE_READY -> { + if (currentMediaItem == null) { + player.currentMediaItem?.let { setMediaItem(it) } + } + } + else -> { // Nothing } } } + /* + * On position discontinuity handle stop session if required + */ override fun onPositionDiscontinuity( eventTime: AnalyticsListener.EventTime, oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int ) { + DebugLogger.debug(TAG, "onPositionDiscontinuity ${StringUtil.discontinuityReasonString(reason)} ${player.currentMediaItem.prettyString()}") val oldPositionMs = oldPosition.positionMs when (reason) { Player.DISCONTINUITY_REASON_REMOVE -> stopSession(MediaItemTracker.StopReason.Stop, oldPositionMs) @@ -159,11 +156,28 @@ internal class CurrentMediaItemTracker internal constructor( } } + /* + * Event received after position_discontinuity + * if MediaItemTracker are using AnalyticsListener too + * They may received discontinuity for media item transition. + */ override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - setMediaItem(player.currentMediaItem) + DebugLogger.debug( + TAG, + "onMediaItemTransition ${StringUtil.mediaItemTransitionReasonString(reason)} ${player.currentMediaItem.prettyString()}" + ) + mediaItem?.let { + setMediaItem(it) + } } internal companion object { + private const val TAG = "CurrentMediaItemTracker" + private fun MediaItem?.prettyString(): String { + if (this == null) return "null" + return "$mediaId / ${localConfiguration?.uri} ${getMediaItemTrackerDataOrNull()}" + } + /** * Are equals only checks mediaId and localConfiguration.uri * @@ -176,16 +190,14 @@ internal class CurrentMediaItemTracker internal constructor( return when { m1 == null && m2 == null -> true m1 == null || m2 == null -> false - else -> m1.getIdentifier() == m2.getIdentifier() && m1.localConfiguration == m2.localConfiguration + else -> + m1.mediaId == m2.mediaId && + m1.buildUpon().setTag(null).build().localConfiguration?.uri == m2.buildUpon().setTag(null).build().localConfiguration?.uri } } private fun MediaItem.canHaveTrackingSession(): Boolean { return this.getMediaItemTrackerDataOrNull() != null } - - private fun MediaItem.getIdentifier(): String? { - return if (mediaId == MediaItem.DEFAULT_MEDIA_ID) localConfiguration?.uri?.toString() else mediaId - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt index bb8843b93..927def4be 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTracker.kt @@ -42,7 +42,7 @@ interface MediaItemTracker { * * @param data The data to use with this Tracker. */ - fun update(data: Any) {} + // fun update(data: Any) {} /** * Factory diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt index 735cd6fb6..39c6c8746 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerData.kt @@ -17,6 +17,16 @@ class MediaItemTrackerData private constructor(private val map: Map, An return map.keys } + /** + * Is empty + */ + val isEmpty: Boolean = map.isEmpty() + + /** + * Is not empty + */ + val isNotEmpty: Boolean = !isEmpty + /** * Get data for a Tracker * diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPlayerMediaItemTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPlayerMediaItemTest.kt new file mode 100644 index 000000000..dc3df3c4f --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/PillarboxPlayerMediaItemTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.test.utils.FakeClock +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.extension.getCurrentMediaItems +import org.junit.Before +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class PillarboxPlayerMediaItemTest { + private lateinit var player: PillarboxPlayer + + @Before + fun createPlayer() { + val context = ApplicationProvider.getApplicationContext() + player = PillarboxPlayer( + context = context, + seekIncrement = SeekIncrement(), + loadControl = DefaultLoadControl(), + clock = FakeClock(true), + ) + } + + @Test(expected = IllegalStateException::class) + fun `set MediaItem without uri throw error`() { + val mediaItem = MediaItem.Builder() + .setMediaId("MediaID") + .build() + player.setMediaItem(mediaItem) + } + + @Test + fun `set MediaItem clear tag`() { + val mediaItem = MEDIA_1_WITH_TAG + val expectedMediaItem = mediaItem.buildUpon().setTag(null).build() + + player.setMediaItem(mediaItem) + assertEquals(expectedMediaItem, player.currentMediaItem) + + player.setMediaItem(mediaItem, false) + assertEquals(expectedMediaItem, player.currentMediaItem) + + player.setMediaItem(mediaItem, 10L) + assertEquals(expectedMediaItem, player.currentMediaItem) + } + + @Test + fun `add MediaItem clear tag`() { + val mediaItem = MEDIA_1_WITH_TAG + val expectedMediaItem = mediaItem.buildUpon().setTag(null).build() + + player.addMediaItem(mediaItem) + assertEquals(expectedMediaItem, player.currentMediaItem) + + player.clearMediaItems() + + player.addMediaItem(0, mediaItem) + assertEquals(expectedMediaItem, player.currentMediaItem) + } + + @Test + fun `replace MediaItem clear tag`() { + val mediaItem = MEDIA_1_WITH_TAG + val expectedMediaItem = mediaItem.buildUpon().setTag(null).build() + + player.setMediaItem(mediaItem) + assertEquals(expectedMediaItem, player.currentMediaItem) + + val expectedMediaItem2 = MEDIA_2_WITH_TAG.buildUpon().setTag(null).build() + player.replaceMediaItem(0, MEDIA_2_WITH_TAG) + assertEquals(expectedMediaItem2, player.currentMediaItem) + } + + @Test + fun `set MediaItems clear tag`() { + val mediaItems = listOf(MEDIA_1_WITH_TAG, MEDIA_2_WITH_TAG, MEDIA_3_WITHOUT_TAG) + val expectedMediaItems = listOf( + MEDIA_1_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_2_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_3_WITHOUT_TAG.buildUpon().setTag(null).build(), + ) + player.setMediaItems(mediaItems) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + + player.setMediaItems(mediaItems, false) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + + player.setMediaItems(mediaItems, 0, 10L) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + } + + @Test + fun `add MediaItems clear tag`() { + val mediaItems = listOf(MEDIA_1_WITH_TAG, MEDIA_2_WITH_TAG, MEDIA_3_WITHOUT_TAG) + val expectedMediaItems = listOf( + MEDIA_1_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_2_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_3_WITHOUT_TAG.buildUpon().setTag(null).build(), + ) + player.addMediaItems(mediaItems) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + + player.clearMediaItems() + player.addMediaItems(0, mediaItems) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + } + + @Test + fun `replace MediaItems clear tag`() { + val mediaItems = listOf(MEDIA_1_WITH_TAG, MEDIA_2_WITH_TAG, MEDIA_3_WITHOUT_TAG) + val expectedMediaItems = listOf( + MEDIA_1_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_2_WITH_TAG.buildUpon().setTag(null).build(), + MEDIA_3_WITHOUT_TAG.buildUpon().setTag(null).build(), + ) + player.setMediaItems(mediaItems) + player.replaceMediaItems(0, mediaItems.size, mediaItems) + assertEquals(expectedMediaItems, player.getCurrentMediaItems()) + } + + companion object { + private val MEDIA_1_WITH_TAG = MediaItem.Builder() + .setUri("uri1") + .setTag("TAG1") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Title 1") + .build() + ) + .build() + private val MEDIA_2_WITH_TAG = MediaItem.Builder() + .setMediaId("MediaId_2") + .setUri("uri2") + .setTag("TAG2") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Title 2") + .build() + ) + .build() + private val MEDIA_3_WITHOUT_TAG = MediaItem.Builder() + .setMediaId("MediaId_3") + .setUri("uri3") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Title 3") + .build() + ) + .build() + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt index 9015ca60f..fbe4634d5 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TestPillarboxPlayerPlaybackSpeed.kt @@ -13,7 +13,6 @@ import androidx.media3.test.utils.FakeClock import androidx.media3.test.utils.robolectric.TestPlayerRunHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import ch.srgssr.pillarbox.player.data.MediaItemSource import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed import ch.srgssr.pillarbox.player.test.utils.TestPillarboxRunHelper import org.junit.After @@ -32,11 +31,6 @@ class TestPillarboxPlayerPlaybackSpeed { player = PillarboxPlayer( context = context, clock = FakeClock(true), - mediaItemSource = object : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - return mediaItem - } - } ) } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt index ea68613fc..e3df73bec 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/CurrentMediaItemTrackerAreEqualTest.kt @@ -85,7 +85,7 @@ class CurrentMediaItemTrackerAreEqualTest { val url = "https://streaming.com/video.mp4" val mediaItem = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = null) val mediaItem2 = createMediaItemWithMediaId(mediaId = mediaId, url = url, tag = "Tag2") - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) } @Test @@ -107,7 +107,7 @@ class CurrentMediaItemTrackerAreEqualTest { val mediaItem2 = mediaItem.buildUpon() .setTag("Tag2") .build() - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) } @Test @@ -152,7 +152,7 @@ class CurrentMediaItemTrackerAreEqualTest { } @Test - fun `areNotEqual different data`() { + fun `are Equal different data`() { val mediaItem = MediaItem.Builder() .setUri("https://streaming.com/video.mp4") .setTrackerData(MediaItemTrackerData.Builder().putData(Tracker::class.java, "data1").build()) @@ -161,7 +161,7 @@ class CurrentMediaItemTrackerAreEqualTest { val mediaItem2 = mediaItem.buildUpon() .setTrackerData(mediaItem.getMediaItemTrackerData().buildUpon().putData(Tracker::class.java, "data2").build()) .build() - assertFalse(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) + assertTrue(CurrentMediaItemTracker.areEqual(mediaItem, mediaItem2)) } private class Tracker : MediaItemTracker { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeAssetLoader.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeAssetLoader.kt new file mode 100644 index 000000000..fe43e58b7 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeAssetLoader.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.tracker + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import ch.srgssr.pillarbox.player.asset.Asset +import ch.srgssr.pillarbox.player.asset.AssetLoader + +class FakeAssetLoader(context: Context) : AssetLoader(DefaultMediaSourceFactory(context)) { + + override fun canLoadAsset(mediaItem: MediaItem): Boolean { + return mediaItem.localConfiguration != null + } + + override suspend fun loadAsset(mediaItem: MediaItem): Asset { + val itemBuilder = mediaItem.buildUpon() + val trackerData = if (mediaItem.mediaId == MEDIA_ID_NO_TRACKING_DATA) MediaItemTrackerData.EMPTY + else MediaItemTrackerData.Builder() + .putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data(mediaItem.mediaId)) + .build() + return Asset( + mediaSource = mediaSourceFactory.createMediaSource(itemBuilder.build()), + trackersData = trackerData, + mediaMetadata = mediaItem.mediaMetadata + ) + } + + companion object { + const val MEDIA_ID_1 = "media:1" + const val MEDIA_ID_2 = "media:2" + const val MEDIA_ID_NO_TRACKING_DATA = "media:3" + + val MEDIA_1 = createMediaItem(MEDIA_ID_1) + val MEDIA_2 = createMediaItem(MEDIA_ID_2) + val MEDIA_NO_TRACKING_DATA = createMediaItem(MEDIA_ID_NO_TRACKING_DATA) + + private const val URL = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" + + const val NEAR_END_POSITION_MS = 15_000L // the video has 17 sec duration + + private fun createMediaItem(mediaId: String) = MediaItem.Builder() + .setUri(URL) + .setMediaId(mediaId) + .build() + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt deleted file mode 100644 index 618a2f61e..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemSource.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.tracker - -import androidx.media3.common.MediaItem -import ch.srgssr.pillarbox.player.data.MediaItemSource -import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData -import ch.srgssr.pillarbox.player.extension.setTrackerData - -class FakeMediaItemSource : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - val trackerData = mediaItem.getMediaItemTrackerData() - val itemBuilder = mediaItem.buildUpon() - - if (mediaItem.localConfiguration == null) { - val url = when (mediaItem.mediaId) { - MEDIA_ID_1 -> URL_MEDIA_1 - MEDIA_ID_2 -> URL_MEDIA_2 - else -> URL_MEDIA_3 - } - itemBuilder.setUri(url) - } - - if (mediaItem.mediaId == MEDIA_ID_NO_TRACKING_DATA) return itemBuilder.build() - itemBuilder.setTrackerData( - trackerData.buildUpon().putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data(mediaItem.mediaId)).build() - ) - return itemBuilder.build() - } - - companion object { - const val MEDIA_ID_1 = "media:1" - const val MEDIA_ID_2 = "media:2" - const val MEDIA_ID_NO_TRACKING_DATA = "media:3" - - const val URL_MEDIA_1 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" - const val URL_MEDIA_2 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" - const val URL_MEDIA_3 = "https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8" - - const val NEAR_END_POSITION_MS = 15_000L // the video has 17 sec duration - } -} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt index 30f9fd1be..7f5a33c47 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/FakeMediaItemTracker.kt @@ -11,14 +11,12 @@ class FakeMediaItemTracker : MediaItemTracker { override fun start(player: ExoPlayer, initialData: Any?) { require(initialData is Data) - } - - override fun update(data: Any) { - require(data is Data) + println("start $initialData") } override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { // Nothing + println("stop $reason $positionMs") } class Factory(private val fakeMediaItemTracker: FakeMediaItemTracker) : MediaItemTracker.Factory { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt index d78335f56..1e1492469 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerDataTest.kt @@ -7,6 +7,7 @@ package ch.srgssr.pillarbox.player.tracker import androidx.media3.exoplayer.ExoPlayer import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -19,6 +20,8 @@ class MediaItemTrackerDataTest { val mediaItemTracker2 = MediaItemTracker2() assertTrue(emptyMediaItemTrackerData.trackers.isEmpty()) + assertTrue(emptyMediaItemTrackerData.isEmpty) + assertFalse(emptyMediaItemTrackerData.isNotEmpty) assertNull(emptyMediaItemTrackerData.getData(mediaItemTracker1)) assertNull(emptyMediaItemTrackerData.getDataAs(mediaItemTracker1)) assertNull(emptyMediaItemTrackerData.getData(mediaItemTracker2)) @@ -30,6 +33,8 @@ class MediaItemTrackerDataTest { .build() assertEquals(setOf(mediaItemTracker1::class.java, mediaItemTracker2::class.java), mediaItemTrackerDataUpdated.trackers) + assertFalse(mediaItemTrackerDataUpdated.isEmpty) + assertTrue(mediaItemTrackerDataUpdated.isNotEmpty) assertEquals("Some value", mediaItemTrackerDataUpdated.getData(mediaItemTracker1)) assertEquals("Some value", mediaItemTrackerDataUpdated.getDataAs(mediaItemTracker1)) assertNull(mediaItemTrackerDataUpdated.getData(mediaItemTracker2)) diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt index cf5550d93..0e6435096 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MediaItemTrackerTest.kt @@ -9,7 +9,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.util.Assertions -import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.test.utils.FakeClock import androidx.media3.test.utils.robolectric.RobolectricUtil @@ -20,7 +19,7 @@ import ch.srgssr.pillarbox.player.PillarboxPlayer import ch.srgssr.pillarbox.player.SeekIncrement import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerData import ch.srgssr.pillarbox.player.extension.getMediaItemTrackerDataOrNull -import ch.srgssr.pillarbox.player.extension.setTrackerData +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import io.mockk.clearAllMocks import io.mockk.confirmVerified import io.mockk.spyk @@ -29,10 +28,10 @@ import io.mockk.verifyAll import io.mockk.verifyOrder import org.junit.After import org.junit.Before -import org.junit.Test import org.junit.runner.RunWith import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test import kotlin.test.assertNotNull @RunWith(AndroidJUnit4::class) @@ -49,11 +48,12 @@ class MediaItemTrackerTest { fakeClock = FakeClock(true) player = PillarboxPlayer( context = context, - dataSourceFactory = DefaultHttpDataSource.Factory(), seekIncrement = SeekIncrement(), loadControl = DefaultLoadControl(), clock = fakeClock, - mediaItemSource = FakeMediaItemSource(), + mediaSourceFactory = PillarboxMediaSourceFactory(context).apply { + addAssetLoader(FakeAssetLoader(context)) + }, mediaItemTrackerProvider = FakeTrackerProvider(fakeMediaItemTracker) ) } @@ -66,19 +66,16 @@ class MediaItemTrackerTest { @Test fun `Player toggle tracking enabled call stop`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + val mediaItem = FakeAssetLoader.MEDIA_1 + val mediaId = mediaItem.mediaId player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .build() - ) + setMediaItem(mediaItem) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.seekTo(FakeAssetLoader.NEAR_END_POSITION_MS) player.trackingEnabled = false verifyOrder { @@ -90,19 +87,16 @@ class MediaItemTrackerTest { @Test fun `Player toggle tracking enabled true false call stop start`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + val mediaItem = FakeAssetLoader.MEDIA_1 + val mediaId = mediaItem.mediaId player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .build() - ) + setMediaItem(mediaItem) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.seekTo(FakeAssetLoader.NEAR_END_POSITION_MS) player.trackingEnabled = false player.trackingEnabled = true @@ -111,27 +105,22 @@ class MediaItemTrackerTest { fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) } - verify(exactly = 0) { - fakeMediaItemTracker.update(any()) - } + confirmVerified(fakeMediaItemTracker) } @Test - fun `one MediaItem with mediaId set reach EoF`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + fun `one MediaItem reach EoF`() { + val mediaItem = FakeAssetLoader.MEDIA_1 + val mediaId = mediaItem.mediaId player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .build() - ) + setMediaItem(mediaItem) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.seekTo(FakeAssetLoader.NEAR_END_POSITION_MS) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) verifyOrder { @@ -142,14 +131,10 @@ class MediaItemTrackerTest { } @Test - fun `one MediaItem with mediaId set reach stop`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + fun `one MediaItem reach stop`() { + val mediaId = FakeAssetLoader.MEDIA_ID_1 player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .build() - ) + setMediaItem(FakeAssetLoader.MEDIA_1) prepare() play() } @@ -165,47 +150,19 @@ class MediaItemTrackerTest { } @Test - fun `one MediaItem with mediaId and url set reach eof`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + fun `one MediaItem reach eof then seek back`() { + val mediaItem = FakeAssetLoader.MEDIA_1 + val mediaId = mediaItem.mediaId player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .setUri(FakeMediaItemSource.URL_MEDIA_1) - .build() - ) + setMediaItem(mediaItem) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) + player.seekTo(FakeAssetLoader.NEAR_END_POSITION_MS) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) - verifyOrder { - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) - fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.EoF, player.currentPosition) - } - confirmVerified(fakeMediaItemTracker) - } - - @Test - fun `one MediaItem with mediaId and url set reach eof then seek back`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 - player.apply { - setMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .setUri(FakeMediaItemSource.URL_MEDIA_1) - .build() - ) - prepare() - play() - } - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) player.seekBack() TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) @@ -219,46 +176,12 @@ class MediaItemTrackerTest { } @Test - fun `one MediaItem with mediaId and url set reach stop`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 - player.apply { - setMediaItem( - MediaItem.Builder() - .setUri(FakeMediaItemSource.URL_MEDIA_1) - .setMediaId(mediaId) - .build() - ) - prepare() - play() - } - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.stop() - - verifyOrder { - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(mediaId)) - fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, player.currentPosition) - } - confirmVerified(fakeMediaItemTracker) - } - - @Test - fun `Playlist of different items with media id and url set transition`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `item seek to another item stop current tracker and start the other`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 + val secondMediaId = FakeAssetLoader.MEDIA_ID_2 player.apply { - addMediaItem( - MediaItem.Builder() - .setUri(FakeMediaItemSource.URL_MEDIA_1) - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setUri(FakeMediaItemSource.URL_MEDIA_2) - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() play() } @@ -279,26 +202,18 @@ class MediaItemTrackerTest { } @Test - fun `Playlist with items without tracking transition doesn't call start`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_NO_TRACKING_DATA + fun `item seek to another item without tracker stop current tracker`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 + val secondMediaId = FakeAssetLoader.MEDIA_ID_NO_TRACKING_DATA player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_NO_TRACKING_DATA) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekTo(1, FakeMediaItemSource.NEAR_END_POSITION_MS) + player.seekTo(1, FakeAssetLoader.NEAR_END_POSITION_MS) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) verifyOrder { @@ -311,46 +226,11 @@ class MediaItemTrackerTest { confirmVerified(fakeMediaItemTracker) } - @Test - fun `Playlist of different items with media id set transition`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 - player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) - prepare() - play() - } - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - player.seekToDefaultPosition(1) - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - - verifyOrder { - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) - fakeMediaItemTracker.stop(any(), MediaItemTracker.StopReason.Stop, any()) - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(secondMediaId)) - } - confirmVerified(fakeMediaItemTracker) - } - @Test fun `remove current item call stop`() { - val mediaId = FakeMediaItemSource.MEDIA_ID_1 + val mediaId = FakeAssetLoader.MEDIA_ID_1 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(mediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) prepare() play() } @@ -366,20 +246,12 @@ class MediaItemTrackerTest { } @Test - fun `playlist remove current item start next item`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `remove current item start next item`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 + val secondMediaId = FakeAssetLoader.MEDIA_ID_2 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() play() } @@ -400,19 +272,11 @@ class MediaItemTrackerTest { } @Test - fun `playlist replace current item by changing media meta data only`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + fun `replace current item by changing media meta does nothing`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(FakeMediaItemSource.MEDIA_ID_2) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() play() } @@ -428,9 +292,6 @@ class MediaItemTrackerTest { .build() player.replaceMediaItem(player.currentMediaItemIndex, mediaUpdate) - verify(exactly = 0) { - fakeMediaItemTracker.update(any()) - } verify(exactly = 1) { fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) } @@ -438,20 +299,11 @@ class MediaItemTrackerTest { } @Test - fun `playlist replace current item update current tracker with same data should not call update`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `replace current item with tracker data or tag does nothing`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() play() } @@ -463,13 +315,12 @@ class MediaItemTrackerTest { val mediaItem = player.currentMediaItem assertNotNull(mediaItem) val mediaUpdate = mediaItem.buildUpon() - .setTrackerData( - mediaItem.getMediaItemTrackerData().buildUpon().build() - ) + // .setTrackerData(mediaItem.getMediaItemTrackerData().buildUpon().build()) .build() + println("replace media item") player.replaceMediaItem(0, mediaUpdate) TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - val waitToPosition = player.currentPosition + 1000 + val waitToPosition = player.currentPosition + 3000 RobolectricUtil.runMainLooperUntil { player.currentPosition >= waitToPosition } @@ -477,155 +328,101 @@ class MediaItemTrackerTest { verifyAll { fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) } - verify(exactly = 0) { - fakeMediaItemTracker.update(any()) - } confirmVerified(fakeMediaItemTracker) } @Test - fun `playlist replace current item update current tracker with null data should not call update`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `replace current item with different item stop current tracker`() { player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + setMediaItem(FakeAssetLoader.MEDIA_1) prepare() play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) RobolectricUtil.runMainLooperUntil { - player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null + player.currentMediaItem?.getMediaItemTrackerData()?.getData(fakeMediaItemTracker) == FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_1) } - val mediaItem = player.currentMediaItem - assertNotNull(mediaItem) - val mediaUpdate = mediaItem.buildUpon() - .setTrackerData( - mediaItem.getMediaItemTrackerData().buildUpon() - .putData(FakeMediaItemTracker::class.java, null) - .build() - ) - .build() - player.replaceMediaItem(0, mediaUpdate) - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - val waitToPosition = player.currentPosition + 1000 + + player.replaceMediaItem(0, FakeAssetLoader.MEDIA_2) + TestPlayerRunHelper.runUntilTimelineChanged(player) RobolectricUtil.runMainLooperUntil { - player.currentPosition >= waitToPosition + player.currentMediaItem?.getMediaItemTrackerData()?.getData(fakeMediaItemTracker) == FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_2) } + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) - verifyAll { - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) - } - verify(exactly = 0) { - fakeMediaItemTracker.update(any()) + verifyOrder { + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_1)) + fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.Stop, player.currentPosition) + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_2)) } confirmVerified(fakeMediaItemTracker) } @Test - fun `playlist replace current item update current tracker`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `auto transition to next item stop current tracker`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 + val secondMediaId = FakeAssetLoader.MEDIA_ID_2 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() + seekTo(FakeAssetLoader.NEAR_END_POSITION_MS) play() } TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - RobolectricUtil.runMainLooperUntil { - player.currentMediaItem?.getMediaItemTrackerDataOrNull() != null - } - val mediaItem = player.currentMediaItem - assertNotNull(mediaItem) - val mediaUpdate = mediaItem.buildUpon() - .setTrackerData( - mediaItem.getMediaItemTrackerData().buildUpon().putData( - FakeMediaItemTracker::class.java, - FakeMediaItemTracker.Data("New tracker data") - ).build() - ) - .build() - player.replaceMediaItem(0, mediaUpdate) - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - val waitToPosition = player.currentPosition + 1000 - RobolectricUtil.runMainLooperUntil { - player.currentPosition >= waitToPosition - } - verifyAll { - fakeMediaItemTracker.start(any(), FakeMediaItemTracker.Data(firstMediaId)) - fakeMediaItemTracker.update(FakeMediaItemTracker.Data("New tracker data")) + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + verifyOrder { + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.EoF, any()) + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(secondMediaId)) + fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.EoF, any()) } confirmVerified(fakeMediaItemTracker) } @Test - fun `playlist auto transition stop current tracker`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `skip next stop current tracker`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 + val secondMediaId = FakeAssetLoader.MEDIA_ID_2 player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) prepare() play() - seekTo(FakeMediaItemSource.NEAR_END_POSITION_MS) } - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + RobolectricUtil.runMainLooperUntil { + val item = player.currentMediaItem + item?.getMediaItemTrackerDataOrNull() != null + } + + player.seekToNextMediaItem() + TestPlayerRunHelper.runUntilTimelineChanged(player) - fakeClock.advanceTime(FakeMediaItemSource.NEAR_END_POSITION_MS) + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) verifyOrder { fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(firstMediaId)) - fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.EoF, any()) + fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.Stop, any()) fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(secondMediaId)) } confirmVerified(fakeMediaItemTracker) } @Test - fun `playlist skip next stop current tracker`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 - val secondMediaId = FakeMediaItemSource.MEDIA_ID_2 + fun `skip previous stop current tracker`() { player.apply { - addMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build() - ) - addMediaItem( - MediaItem.Builder() - .setMediaId(secondMediaId) - .build() - ) + addMediaItem(FakeAssetLoader.MEDIA_1) + addMediaItem(FakeAssetLoader.MEDIA_2) + seekTo(1, 0) prepare() play() } @@ -636,29 +433,27 @@ class MediaItemTrackerTest { item?.getMediaItemTrackerDataOrNull() != null } - player.seekToNextMediaItem() + player.seekToPreviousMediaItem() TestPlayerRunHelper.runUntilTimelineChanged(player) TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) verifyOrder { - fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(firstMediaId)) + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_2)) fakeMediaItemTracker.stop(player, MediaItemTracker.StopReason.Stop, any()) - fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(secondMediaId)) + fakeMediaItemTracker.start(player, FakeMediaItemTracker.Data(FakeAssetLoader.MEDIA_ID_1)) } confirmVerified(fakeMediaItemTracker) } @Test - fun `playlist repeat current item reset current tracker`() { - val firstMediaId = FakeMediaItemSource.MEDIA_ID_1 + fun `repeat current item stop with EoF when start again`() { + val firstMediaId = FakeAssetLoader.MEDIA_ID_1 player.apply { setMediaItem( - MediaItem.Builder() - .setMediaId(firstMediaId) - .build(), - FakeMediaItemSource.NEAR_END_POSITION_MS + FakeAssetLoader.MEDIA_1, + FakeAssetLoader.NEAR_END_POSITION_MS ) player.repeatMode = Player.REPEAT_MODE_ONE prepare() diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt deleted file mode 100644 index 1794d6a61..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/tracker/MultiMediaItemTrackerUpdate.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.tracker - -import android.content.Context -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.datasource.DefaultHttpDataSource -import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.test.utils.FakeClock -import androidx.media3.test.utils.robolectric.TestPlayerRunHelper -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import ch.srgssr.pillarbox.player.PillarboxPlayer -import ch.srgssr.pillarbox.player.SeekIncrement -import ch.srgssr.pillarbox.player.data.MediaItemSource -import ch.srgssr.pillarbox.player.extension.setTrackerData -import io.mockk.clearAllMocks -import io.mockk.spyk -import io.mockk.verify -import io.mockk.verifyOrder -import org.junit.After -import org.junit.Before -import org.junit.runner.RunWith -import kotlin.test.Test - -@RunWith(AndroidJUnit4::class) -class MultiMediaItemTrackerUpdate { - private lateinit var fakeClock: FakeClock - - @Before - fun createPlayer() { - fakeClock = FakeClock(true) - } - - @After - fun releasePlayer() { - clearAllMocks() - } - - @Test - fun `Remove one tracker data update other tracker data when initialized both in MediaItemSource`() { - val context = ApplicationProvider.getApplicationContext() - val fakeMediaItemTracker = spyk(FakeMediaItemTracker()) - val dummyMediaItemTracker = spyk(DummyTracker()) - - val player = PillarboxPlayer( - context = context, - dataSourceFactory = DefaultHttpDataSource.Factory(), - seekIncrement = SeekIncrement(), - loadControl = DefaultLoadControl(), - clock = fakeClock, - mediaItemSource = object : MediaItemSource { - override suspend fun loadMediaItem(mediaItem: MediaItem): MediaItem { - val trackerData = MediaItemTrackerData.Builder() - .putData(DummyTracker::class.java, "DummyItemTracker") - .putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data("FakeMediaItemTracker")) - .build() - return mediaItem.buildUpon() - .setUri(FakeMediaItemSource.URL_MEDIA_1) - .setTrackerData(trackerData) - .build() - } - }, - mediaItemTrackerProvider = MediaItemTrackerRepository().apply { - registerFactory(DummyTracker::class.java, DummyTracker.Factory(dummyMediaItemTracker)) - registerFactory(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Factory(fakeMediaItemTracker)) - } - ) - player.apply { - player.setMediaItem( - MediaItem.Builder() - .setMediaId(FakeMediaItemSource.MEDIA_ID_1) - .build() - ) - prepare() - play() - } - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - - val currentMediaItem = player.currentMediaItem!! - val mediaUpdate = currentMediaItem.buildUpon() - .setTrackerData( - MediaItemTrackerData.Builder() - .putData(FakeMediaItemTracker::class.java, FakeMediaItemTracker.Data("New Data")) - .build() - ) - .build() - player.replaceMediaItem(0, mediaUpdate) - - verify(exactly = 0) { - dummyMediaItemTracker.update(any()) - } - - verify(exactly = 1) { - dummyMediaItemTracker.start(any(), any()) - } - - verifyOrder { - fakeMediaItemTracker.start(any(), any()) - fakeMediaItemTracker.update(FakeMediaItemTracker.Data("New Data")) - } - player.release() - } - - internal class DummyTracker : MediaItemTracker { - - override fun start(player: ExoPlayer, initialData: Any?) { - // Nothing it is dummy - } - - override fun stop(player: ExoPlayer, reason: MediaItemTracker.StopReason, positionMs: Long) { - // Nothing is is dummy - } - - class Factory(private val dummyTracker: DummyTracker = DummyTracker()) : MediaItemTracker.Factory { - override fun create(): MediaItemTracker { - return dummyTracker - } - } - } -} diff --git a/pillarbox-ui/docs/README.md b/pillarbox-ui/docs/README.md index f75ce4d12..5f785d5eb 100644 --- a/pillarbox-ui/docs/README.md +++ b/pillarbox-ui/docs/README.md @@ -9,6 +9,7 @@ Provides UI Compose components : - PlayerSurface - Exoplayer views compose wrappers +- ProgressTrackers to connect the player to a progress bar or slider. ## Integration @@ -66,7 +67,6 @@ In this example we use `ScaleMode.Fit` to fit the content to the parent containe - `ScaleMode.Fit` : Fit player content to the parent container and keep aspect ratio. - `ScaleMode.Fill` : Fill player content to the parent container. - `ScaleMode.Crop` : Crop player content inside the parent container and keep aspect ratio. Content outside the parent container will be clipped. -- `ScaleMode.Zoom`: Like _Crop_ but doesn't clip content to the parent container. Useful for fullscreen mode ### Listen to player states