diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62995fa89..bc6da0054 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ json = "20240303" junit = "4.13.2" kotlin = "2.0.21" kotlinx-coroutines = "1.9.0" +kotlinx-datetime = "0.6.1" kotlinx-kover = "0.8.3" kotlinx-serialization = "1.7.3" ktor = "3.0.0" @@ -69,6 +70,7 @@ kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v kotlin-parcelize-runtime = { module = "org.jetbrains.kotlin:kotlin-parcelize-runtime", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-kover-gradle = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kotlinx-kover" } 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" } diff --git a/pillarbox-core-business/build.gradle.kts b/pillarbox-core-business/build.gradle.kts index 774b02fc4..5daad1dbc 100644 --- a/pillarbox-core-business/build.gradle.kts +++ b/pillarbox-core-business/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.guava) runtimeOnly(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) + api(libs.kotlinx.datetime) api(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.content.negotiation) diff --git a/pillarbox-core-business/docs/README.md b/pillarbox-core-business/docs/README.md index f2c7ed070..df960d1db 100644 --- a/pillarbox-core-business/docs/README.md +++ b/pillarbox-core-business/docs/README.md @@ -68,7 +68,8 @@ All exceptions thrown by [`PillarboxMediaSource`][pillarbox-media-source-source] player.addListener(object : Player.Listener { override fun onPlayerError(error: PlaybackException) { when (val cause = error.cause) { - is BlockReasonException -> Log.d("Pillarbox", "Content blocked: ${cause.blockReason}") + is BlockReasonException.StartDate -> Log.d("Pillarbox", "Content is blocked until ${cause.instant}") + is BlockReasonException -> Log.d("Pillarbox", "Content is blocked", cause) is ResourceNotFoundException -> Log.d("Pillarbox", "No resources found in the chapter") else -> Log.d("Pillarbox", "An error occurred", cause) } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt index 5a0efe247..8e70b6c92 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProvider.kt @@ -12,7 +12,6 @@ import androidx.media3.datasource.DataSourceException 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.extension.getString import java.io.IOException /** @@ -23,7 +22,7 @@ class SRGErrorMessageProvider(private val context: Context) : ErrorMessageProvid override fun getErrorMessage(throwable: PlaybackException): Pair { return when (val cause = throwable.cause) { is BlockReasonException -> { - Pair.create(0, context.getString(cause.blockReason)) + Pair.create(0, context.getString(cause.messageResId)) } is ResourceNotFoundException -> { diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonException.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonException.kt index 03bf6d3eb..ebc043fde 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonException.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonException.kt @@ -4,31 +4,95 @@ */ package ch.srgssr.pillarbox.core.business.exception +import androidx.annotation.StringRes +import ch.srgssr.pillarbox.core.business.R 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.Segment +import kotlinx.datetime.Instant import java.io.IOException /** * Block reason exception - * - * @property blockReason the reason a [Chapter] or a [Segment] is blocked. */ -class BlockReasonException(val blockReason: BlockReason) : IOException(blockReason.name) { - /* - * ExoPlaybackException bundles cause exception with class name and message. - * In order to recreate the cause of the throwable, it needs a throwable class with constructor(string). - */ - internal constructor(message: String) : this(parseMessage(message)) - - private companion object { - @Suppress("SwallowedException") - private fun parseMessage(message: String): BlockReason { - return try { - enumValueOf(message) - } catch (e: IllegalArgumentException) { - BlockReason.UNKNOWN - } - } +sealed class BlockReasonException(message: String) : IOException(message) { + /** + * The Android resource id of the message to display. + */ + @StringRes + open val messageResId: Int = R.string.blockReason_unknown + + private constructor(blockReason: BlockReason) : this(blockReason.name) + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.STARTDATE]. + * + * @property instant The [Instant] when the content will be available. + */ + class StartDate(val instant: Instant?) : BlockReasonException(BlockReason.STARTDATE) { + override val messageResId: Int + get() = R.string.blockReason_startDate + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.ENDDATE]. + * + * @property instant The [Instant] since it is unavailable. + */ + class EndDate(val instant: Instant?) : BlockReasonException(BlockReason.ENDDATE) { + override val messageResId: Int + get() = R.string.blockReason_endDate } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.LEGAL]. + */ + class Legal : BlockReasonException(BlockReason.LEGAL) { + override val messageResId: Int + get() = R.string.blockReason_legal + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.AGERATING18]. + */ + class AgeRating18 : BlockReasonException(BlockReason.AGERATING18) { + override val messageResId: Int + get() = R.string.blockReason_ageRating18 + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.AGERATING12]. + */ + class AgeRating12 : BlockReasonException(BlockReason.AGERATING12) { + override val messageResId: Int + get() = R.string.blockReason_ageRating12 + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.GEOBLOCK]. + */ + class GeoBlock : BlockReasonException(BlockReason.GEOBLOCK) { + override val messageResId: Int + get() = R.string.blockReason_geoBlock + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.COMMERCIAL]. + */ + class Commercial : BlockReasonException(BlockReason.COMMERCIAL) { + override val messageResId: Int + get() = R.string.blockReason_commercial + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.JOURNALISTIC]. + */ + class Journalistic : BlockReasonException(BlockReason.JOURNALISTIC) { + override val messageResId: Int + get() = R.string.blockReason_journalistic + } + + /** + * [BlockReasonException] when [Chapter.blockReason] is [BlockReason.UNKNOWN]. + */ + class Unknown : BlockReasonException(BlockReason.UNKNOWN) } diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/BlockReason.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/BlockReason.kt deleted file mode 100644 index b6d5d773d..000000000 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/BlockReason.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business.extension - -import android.content.Context -import androidx.annotation.StringRes -import ch.srgssr.pillarbox.core.business.R -import ch.srgssr.pillarbox.core.business.integrationlayer.data.BlockReason - -/** - * Get string - * - * @param blockReason The [BlockReason] to get the string of. - * @return The string message of [blockReason] - */ -fun Context.getString(blockReason: BlockReason): String { - return getString(blockReason.getStringResId()) -} - -/** - * Get string resource id - * - * @return The android string resource id of a [BlockReason] - */ -@StringRes -fun BlockReason.getStringResId(): Int { - return when (this) { - BlockReason.AGERATING12 -> R.string.blockReason_ageRating12 - BlockReason.GEOBLOCK -> R.string.blockReason_geoBlock - BlockReason.LEGAL -> R.string.blockReason_legal - BlockReason.COMMERCIAL -> R.string.blockReason_commercial - BlockReason.AGERATING18 -> R.string.blockReason_ageRating18 - BlockReason.STARTDATE -> R.string.blockReason_startDate - BlockReason.ENDDATE -> R.string.blockReason_endDate - BlockReason.JOURNALISTIC -> R.string.blockReason_journalistic - BlockReason.UNKNOWN -> R.string.blockReason_unknown - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/Chapter.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/Chapter.kt new file mode 100644 index 000000000..6079d1760 --- /dev/null +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/extension/Chapter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.core.business.extension + +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.AgeRating12 +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.AgeRating18 +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.Commercial +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.EndDate +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.GeoBlock +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.Journalistic +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.Legal +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.StartDate +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException.Unknown +import ch.srgssr.pillarbox.core.business.integrationlayer.data.BlockReason +import ch.srgssr.pillarbox.core.business.integrationlayer.data.Chapter + +/** + * @return The [BlockReasonException] linked to [Chapter.blockReason] or `null` if there is no block reason. + */ +fun Chapter.getBlockReasonExceptionOrNull(): BlockReasonException? { + return when (blockReason) { + null -> null + BlockReason.STARTDATE -> StartDate(instant = validFrom) + BlockReason.ENDDATE -> EndDate(instant = validTo) + BlockReason.LEGAL -> Legal() + BlockReason.AGERATING18 -> AgeRating18() + BlockReason.AGERATING12 -> AgeRating12() + BlockReason.GEOBLOCK -> GeoBlock() + BlockReason.COMMERCIAL -> Commercial() + BlockReason.JOURNALISTIC -> Journalistic() + BlockReason.UNKNOWN -> Unknown() + } +} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/Chapter.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/Chapter.kt index 15ac281c9..a4c8632aa 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/Chapter.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/integrationlayer/data/Chapter.kt @@ -4,6 +4,7 @@ */ package ch.srgssr.pillarbox.core.business.integrationlayer.data +import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,6 +26,8 @@ import kotlinx.serialization.Serializable * @property comScoreAnalyticsLabels * @property analyticsLabels * @property timeIntervalList + * @property validFrom The [Instant] when the [Chapter] becomes valid. + * @property validTo The [Instant] until when the [Chapter] is valid. * @constructor Create empty Chapter */ @Serializable @@ -47,6 +50,8 @@ data class Chapter( @SerialName("analyticsMetadata") override val analyticsLabels: Map? = null, val timeIntervalList: List? = null, + val validFrom: Instant? = null, + val validTo: Instant? = null, ) : DataWithAnalytics { /** * If it is a full length chapter. 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 index 55f189e54..31e43c416 100644 --- 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 @@ -17,9 +17,9 @@ import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct 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.extension.getBlockReasonExceptionOrNull 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 @@ -145,8 +145,8 @@ class SRGAssetLoader internal constructor( } val chapter = result.mainChapter - chapter.blockReason?.let { - throw BlockReasonException(it) + chapter.getBlockReasonExceptionOrNull()?.let { + throw it } val resource = resourceSelector.selectResourceFromChapter(chapter) ?: throw ResourceNotFoundException() diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt index 701fbb08c..686399868 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/SRGErrorMessageProviderTest.kt @@ -14,7 +14,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 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.BlockReason import org.junit.runner.RunWith import java.io.IOException import kotlin.test.BeforeTest @@ -34,7 +33,7 @@ class SRGErrorMessageProviderTest { @Test fun `getErrorMessage BlockReasonException`() { - val exception = BlockReasonException(BlockReason.AGERATING12) + val exception = BlockReasonException.AgeRating12() val (errorCode, errorMessage) = errorMessageProvider.getErrorMessage(playbackException(exception)) assertEquals(0, errorCode) diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonExceptionTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonExceptionTest.kt index de4918657..8a4a21793 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonExceptionTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/exception/BlockReasonExceptionTest.kt @@ -4,33 +4,98 @@ */ package ch.srgssr.pillarbox.core.business.exception +import ch.srgssr.pillarbox.core.business.R +import ch.srgssr.pillarbox.core.business.extension.getBlockReasonExceptionOrNull 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.MediaType +import kotlinx.datetime.Clock import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull class BlockReasonExceptionTest { @Test fun `BlockReasonException created with a BlockReason`() { + val expectedExceptionForBlockReason = mapOf( + BlockReason.STARTDATE to BlockReasonException.StartDate::class, + BlockReason.ENDDATE to BlockReasonException.EndDate::class, + BlockReason.LEGAL to BlockReasonException.Legal::class, + BlockReason.AGERATING18 to BlockReasonException.AgeRating18::class, + BlockReason.AGERATING12 to BlockReasonException.AgeRating12::class, + BlockReason.GEOBLOCK to BlockReasonException.GeoBlock::class, + BlockReason.COMMERCIAL to BlockReasonException.Commercial::class, + BlockReason.JOURNALISTIC to BlockReasonException.Journalistic::class, + BlockReason.UNKNOWN to BlockReasonException.Unknown::class, + ) BlockReason.entries.forEach { blockReason -> - val exception = BlockReasonException(blockReason) - - assertEquals(blockReason.name, exception.message) + val chapter = Chapter( + urn = "id", + title = "chapter", + imageUrl = "", + mediaType = MediaType.VIDEO, + blockReason = blockReason, + ) + val exception = chapter.getBlockReasonExceptionOrNull() + val expectedClass = expectedExceptionForBlockReason[blockReason] + assertNotNull(exception) + assertEquals(exception::class, expectedClass) } } @Test - fun `BlockReasonException created with a message matching a BlockReason`() { - BlockReason.entries.forEach { blockReason -> - val exception = BlockReasonException(blockReason.name) + fun `Chapter without block reason returns null`() { + val chapter = Chapter( + urn = "id", + title = "chapter", + imageUrl = "", + mediaType = MediaType.VIDEO, + ) + assertNull(chapter.getBlockReasonExceptionOrNull()) + } - assertEquals(blockReason.name, exception.message) - } + @Test + fun `Chapter with start date`() { + val chapter = Chapter( + urn = "id", + title = "chapter", + imageUrl = "", + blockReason = BlockReason.STARTDATE, + validFrom = Clock.System.now(), + mediaType = MediaType.VIDEO, + ) + val exception = chapter.getBlockReasonExceptionOrNull() + assertIs(exception) + assertEquals(chapter.validFrom, exception.instant) } @Test - fun `BlockReasonException created with a message not matching a BlockReason`() { - val exception = BlockReasonException("FOO_BAR") + fun `Chapter with end date`() { + val chapter = Chapter( + urn = "id", + title = "chapter", + imageUrl = "", + blockReason = BlockReason.ENDDATE, + validTo = Clock.System.now(), + mediaType = MediaType.VIDEO, + ) + val exception = chapter.getBlockReasonExceptionOrNull() + assertIs(exception) + assertEquals(chapter.validTo, exception.instant) + } - assertEquals(BlockReason.UNKNOWN.name, exception.message) + @Test + fun `get string resId for BlockReasonException`() { + assertEquals(R.string.blockReason_ageRating12, BlockReasonException.AgeRating12().messageResId) + assertEquals(R.string.blockReason_ageRating18, BlockReasonException.AgeRating18().messageResId) + assertEquals(R.string.blockReason_commercial, BlockReasonException.Commercial().messageResId) + assertEquals(R.string.blockReason_endDate, BlockReasonException.EndDate(null).messageResId) + assertEquals(R.string.blockReason_geoBlock, BlockReasonException.GeoBlock().messageResId) + assertEquals(R.string.blockReason_legal, BlockReasonException.Legal().messageResId) + assertEquals(R.string.blockReason_startDate, BlockReasonException.StartDate(null).messageResId) + assertEquals(R.string.blockReason_journalistic, BlockReasonException.Journalistic().messageResId) + assertEquals(R.string.blockReason_unknown, BlockReasonException.Unknown().messageResId) } } diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/extension/BlockReasonTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/extension/BlockReasonTest.kt deleted file mode 100644 index 84d3f0305..000000000 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/extension/BlockReasonTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.core.business.extension - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import ch.srgssr.pillarbox.core.business.R -import ch.srgssr.pillarbox.core.business.integrationlayer.data.BlockReason -import org.junit.runner.RunWith -import kotlin.test.Test -import kotlin.test.assertEquals - -@RunWith(AndroidJUnit4::class) -class BlockReasonTest { - @Test - fun `getString() for BlockReason via Context`() { - val context = ApplicationProvider.getApplicationContext() - - assertEquals("To protect children this content is only available between 8PM and 6AM.", context.getString(BlockReason.AGERATING12)) - assertEquals("To protect children this content is only available between 10PM and 5AM.", context.getString(BlockReason.AGERATING18)) - assertEquals("This commercial content is not available.", context.getString(BlockReason.COMMERCIAL)) - assertEquals("This content is not available anymore.", context.getString(BlockReason.ENDDATE)) - assertEquals("This content is not available outside Switzerland.", context.getString(BlockReason.GEOBLOCK)) - assertEquals("This content is not available due to legal restrictions.", context.getString(BlockReason.LEGAL)) - assertEquals("This content is not available yet.", context.getString(BlockReason.STARTDATE)) - assertEquals("This content is temporarily unavailable for journalistic reasons.", context.getString(BlockReason.JOURNALISTIC)) - assertEquals("This content is not available.", context.getString(BlockReason.UNKNOWN)) - } - - @Test - fun `get string resId for BlockReason`() { - assertEquals(R.string.blockReason_ageRating12, BlockReason.AGERATING12.getStringResId()) - assertEquals(R.string.blockReason_ageRating18, BlockReason.AGERATING18.getStringResId()) - assertEquals(R.string.blockReason_commercial, BlockReason.COMMERCIAL.getStringResId()) - assertEquals(R.string.blockReason_endDate, BlockReason.ENDDATE.getStringResId()) - assertEquals(R.string.blockReason_geoBlock, BlockReason.GEOBLOCK.getStringResId()) - assertEquals(R.string.blockReason_legal, BlockReason.LEGAL.getStringResId()) - assertEquals(R.string.blockReason_startDate, BlockReason.STARTDATE.getStringResId()) - assertEquals(R.string.blockReason_journalistic, BlockReason.JOURNALISTIC.getStringResId()) - assertEquals(R.string.blockReason_unknown, BlockReason.UNKNOWN.getStringResId()) - } -} diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt index f44bd3389..d66e08f42 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/HomeDestination.kt @@ -36,7 +36,7 @@ sealed class HomeDestination( /** * Streams home page */ - data object ShowCases : HomeDestination(NavigationRoutes.HomeShowcases, R.string.showcases, Icons.Default.Movie) + data object Showcases : HomeDestination(NavigationRoutes.HomeShowcases, R.string.showcases, Icons.Default.Movie) /** * Integration layer list home page diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt index 92849511e..1d70f3727 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/NavigationRoutes.kt @@ -74,4 +74,7 @@ sealed interface NavigationRoutes { @Serializable data object SettingsHome : NavigationRoutes + + @Serializable + data object CountdownShowcase : NavigationRoutes } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt index d61814211..e7ef1af05 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/MainNavigation.kt @@ -72,7 +72,7 @@ import ch.srgssr.pillarbox.demo.ui.theme.paddings import java.net.URL private val bottomNavItems = - listOf(HomeDestination.Examples, HomeDestination.ShowCases, HomeDestination.Lists, HomeDestination.Search, HomeDestination.Settings) + listOf(HomeDestination.Examples, HomeDestination.Showcases, HomeDestination.Lists, HomeDestination.Search, HomeDestination.Settings) private val topLevelRoutes = listOf( NavigationRoutes.HomeSamples, NavigationRoutes.ShowcaseList, diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/CountdownView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/CountdownView.kt new file mode 100644 index 000000000..0e0d5c1b2 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/player/CountdownView.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme +import kotlinx.coroutines.delay +import kotlinx.datetime.LocalTime +import kotlinx.datetime.format +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.char +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * @param duration Countdown duration. + * @return [CountdownState] what starts the countdown. + */ +@Composable +fun rememberCountdownState(duration: Duration): CountdownState { + val state = remember(duration) { + CountdownState(duration) + } + LaunchedEffect(state) { + state.start() + } + return state +} + +/** + * Count down state + * + * @param duration The countdown duration. + */ +class CountdownState internal constructor(duration: Duration) { + private var countdown by mutableStateOf(duration) + + /** + * Remaining time [LocalTime]. + */ + val remainingTime: State = derivedStateOf { + LocalTime.fromMillisecondOfDay(countdown.inWholeMilliseconds.toInt()) + } + + internal suspend fun start() { + while (countdown > ZERO) { + delay(step) + countdown -= step + } + } + + private companion object { + val step = 1.seconds + } +} + +private val formatHms by lazy { + LocalTime.Format { + hour(Padding.ZERO) + char(':') + minute(Padding.ZERO) + char(':') + second(Padding.ZERO) + } +} + +/** + * Countdown + * + * @param countdownDuration The amount of time until the countdown ends. + * @param modifier The [Modifier] to layouts this view. + */ +@Composable +fun Countdown(countdownDuration: Duration, modifier: Modifier = Modifier) { + val countdownState = rememberCountdownState(countdownDuration) + val remainingTime by countdownState.remainingTime + val text = remainingTime.format(formatHms) + Text(text, modifier = modifier, color = Color.White) +} + +@Preview(showBackground = true) +@Composable +private fun CountdownPreview() { + PillarboxTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Countdown( + countdownDuration = 1.minutes, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt index 2e51ea031..9a9557870 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesHome.kt @@ -193,6 +193,14 @@ fun ShowcasesHome(navController: NavController) { modifier = itemModifier, onClick = { navController.navigate(NavigationRoutes.Video360) } ) + + HorizontalDivider() + + DemoListItemView( + title = stringResource(R.string.showcase_countdown), + modifier = itemModifier, + onClick = { navController.navigate(NavigationRoutes.CountdownShowcase) } + ) } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesNavigation.kt index 0baa95e6c..ba67df6ea 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/ShowcasesNavigation.kt @@ -14,6 +14,7 @@ import ch.srgssr.pillarbox.demo.ui.showcases.integrations.ExoPlayerShowcase import ch.srgssr.pillarbox.demo.ui.showcases.layouts.ChapterShowcase import ch.srgssr.pillarbox.demo.ui.showcases.layouts.SimpleLayoutShowcase import ch.srgssr.pillarbox.demo.ui.showcases.layouts.StoryLayoutShowcase +import ch.srgssr.pillarbox.demo.ui.showcases.misc.ContentNotYetAvailable import ch.srgssr.pillarbox.demo.ui.showcases.misc.MultiPlayerShowcase import ch.srgssr.pillarbox.demo.ui.showcases.misc.ResizablePlayerShowcase import ch.srgssr.pillarbox.demo.ui.showcases.misc.SmoothSeekingShowcase @@ -66,6 +67,10 @@ fun NavGraphBuilder.showcasesNavGraph(navController: NavController) { composable(DemoPageView("Chapters", Levels)) { ChapterShowcase() } + + composable(DemoPageView("CountdownShowcase", Levels)) { + ContentNotYetAvailable() + } } private val Levels = listOf("app", "pillarbox", "showcase") diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt new file mode 100644 index 000000000..7ec426668 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailable.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.showcases.misc + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.PlaybackException +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException +import ch.srgssr.pillarbox.demo.ui.player.Countdown +import ch.srgssr.pillarbox.demo.ui.player.controls.PlayerError +import ch.srgssr.pillarbox.ui.extension.playerErrorAsState +import ch.srgssr.pillarbox.ui.widget.player.PlayerSurface +import kotlinx.coroutines.delay +import kotlinx.datetime.Clock +import kotlin.time.Duration + +/** + * Content not yet available + */ +@Composable +fun ContentNotYetAvailable() { + val viewModel: ContentNotYetAvailableViewModel = viewModel() + val player = viewModel.player + PlayerSurface(player = player) { + val error by player.playerErrorAsState() + error?.let { + ErrorViewWithCountdown( + error = it, + modifier = Modifier.fillMaxSize(), + onCountdownEnd = { + player.prepare() + player.play() + }, + onRetry = player::prepare + ) + } + } +} + +@Composable +private fun ErrorViewWithCountdown( + error: PlaybackException, + modifier: Modifier = Modifier, + onCountdownEnd: () -> Unit = {}, + onRetry: () -> Unit = {}, +) { + val cause = error.cause + when { + cause is BlockReasonException.StartDate && cause.instant != null -> { + val duration = cause.instant!!.minus(Clock.System.now()) + CountdownView(duration, Modifier.fillMaxSize(), onCountdownEnd) + } + + else -> { + PlayerError(playerError = error, modifier = modifier, onRetry = onRetry) + } + } +} + +@Composable +private fun CountdownView(duration: Duration, modifier: Modifier = Modifier, onCountdownEnd: () -> Unit = {}) { + Box( + modifier = modifier, + ) { + LaunchedEffect(Unit) { + delay(duration) + onCountdownEnd() + } + Countdown( + modifier = Modifier.align(Alignment.Center), + countdownDuration = duration + ) + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailableViewModel.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailableViewModel.kt new file mode 100644 index 000000000..b33bceba7 --- /dev/null +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/showcases/misc/ContentNotYetAvailableViewModel.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.ui.showcases.misc + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import ch.srgssr.pillarbox.core.business.DefaultPillarbox +import ch.srgssr.pillarbox.core.business.exception.BlockReasonException +import ch.srgssr.pillarbox.core.business.source.SRGAssetLoader +import ch.srgssr.pillarbox.demo.shared.data.DemoItem +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.asset.Asset +import ch.srgssr.pillarbox.player.asset.AssetLoader +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * ViewModel that load a media that send always a [BlockReasonException.StartDate] during the first minute. + * + * @param application The [Application]. + */ +class ContentNotYetAvailableViewModel(application: Application) : AndroidViewModel(application) { + private class AlwaysStartDateBlockedAssetLoader(context: Context) : AssetLoader(DefaultMediaSourceFactory(context)) { + private val srgAssetLoader = SRGAssetLoader(context) + private val validFrom = Clock.System.now().plus(1.minutes) + override fun canLoadAsset(mediaItem: MediaItem): Boolean { + return srgAssetLoader.canLoadAsset(mediaItem) + } + + override suspend fun loadAsset(mediaItem: MediaItem): Asset { + if (validFrom <= Clock.System.now()) { + return srgAssetLoader.loadAsset(mediaItem) + } + throw BlockReasonException.StartDate(validFrom) + } + } + + /** + * Player + */ + val player: PillarboxExoPlayer = PillarboxExoPlayer( + context = application, + mediaSourceFactory = PillarboxMediaSourceFactory( + context = application + ).apply { + addAssetLoader(AlwaysStartDateBlockedAssetLoader(application)) + }, + monitoringMessageHandler = DefaultPillarbox.defaultMonitoringMessageHandler, + ) + + init { + player.prepare() + player.setMediaItem(DemoItem.OnDemandHorizontalVideo.toMediaItem()) + player.play() + } + + override fun onCleared() { + player.release() + } +} diff --git a/pillarbox-demo/src/main/res/values/strings.xml b/pillarbox-demo/src/main/res/values/strings.xml index 839c6ee19..529112c83 100644 --- a/pillarbox-demo/src/main/res/values/strings.xml +++ b/pillarbox-demo/src/main/res/values/strings.xml @@ -41,4 +41,5 @@ Metrics Overlay Display an overlay on top of the video surface to show useful information. Enable metrics overlay + Content always not yet available