diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91f1ce98..d389a16d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 11101 - versionName = "1.11.0-beta.1" + versionCode = 11102 + versionName = "1.11.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt index 7ffd6f7b..6ff9281c 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt @@ -15,11 +15,13 @@ class DescriptionSectionCountTest { val composeRule = createAndroidComposeRule() @Test - fun unknownViewCountIsHidden() { + fun unknownViewCountExplainsProviderLimit() { setDescription(viewCount = -1) composeRule.onNodeWithText("-1").assertDoesNotExist() - composeRule.onNodeWithContentDescription(viewsLabel("-1")).assertDoesNotExist() + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.video_views_hidden_by_provider), + ).assertIsDisplayed() } @Test @@ -27,7 +29,9 @@ class DescriptionSectionCountTest { setDescription(viewCount = 0) composeRule.onNodeWithText("0").assertIsDisplayed() - composeRule.onNodeWithContentDescription(viewsLabel("0")).assertIsDisplayed() + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_views_count, "0"), + ).assertIsDisplayed() } private fun setDescription(viewCount: Long) { @@ -45,7 +49,4 @@ class DescriptionSectionCountTest { } } } - - private fun viewsLabel(count: String): String = - composeRule.activity.getString(R.string.player_views_count, count) } diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 576bd4ac..3ddc8dc2 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -37,7 +37,9 @@ import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt +import dev.typetype.android.domain.feed.badgeStatusAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.domain.feed.VideoBadgeStatus import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS import dev.typetype.android.feature.player.LocalPlaybackPrewarm import kotlinx.coroutines.delay @@ -177,16 +179,28 @@ private fun formatRelatedViews(views: Long): String = when { @Composable private fun Video.metadataText(): String { - val views = stringResource(R.string.video_views_short, formatRelatedViews(viewCount)) + val nowMillis = System.currentTimeMillis() + val scheduledAt = releaseTimeMillis()?.takeIf { it > nowMillis } + val badge = badgeStatusAt(nowMillis) + val views = when { + viewCount >= 0L -> stringResource(R.string.video_views_short, formatRelatedViews(viewCount)) + scheduledAt != null && + (badge == VideoBadgeStatus.Premiere || badge == VideoBadgeStatus.Upcoming) -> + stringResource(R.string.video_premieres_at, formatRelativeDate(scheduledAt)) + else -> stringResource(R.string.video_views_hidden_by_provider) + } val published = releaseTimeMillis() - ?.takeIf { it <= System.currentTimeMillis() } + ?.takeIf { scheduledAt == null } ?.let { timestamp -> - DateUtils.getRelativeTimeSpanString( - timestamp, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE, - ).toString() + formatRelativeDate(timestamp) } return listOfNotNull(views, published).joinToString(" · ") } + +private fun formatRelativeDate(timestampMillis: Long): String = + DateUtils.getRelativeTimeSpanString( + timestampMillis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ).toString() diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index 85dc515c..d3e8227a 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -45,7 +45,9 @@ import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt +import dev.typetype.android.domain.feed.badgeStatusAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.domain.feed.VideoBadgeStatus import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS import dev.typetype.android.feature.player.LocalPlaybackPrewarm @@ -214,13 +216,24 @@ private fun WatchedBadge(modifier: Modifier = Modifier) { @Composable private fun Video.metadataText(): String? { - val views = formatViews(viewCount)?.let { stringResource(R.string.video_views_short, it) } - val published = releaseTimeMillis()?.let(::formatPublishedDate) + val nowMillis = System.currentTimeMillis() + val scheduledAt = releaseTimeMillis()?.takeIf { it > nowMillis } + val badge = badgeStatusAt(nowMillis) + val views = when { + viewCount >= 0L -> stringResource( + R.string.video_views_short, + formatViews(viewCount), + ) + scheduledAt != null && + (badge == VideoBadgeStatus.Premiere || badge == VideoBadgeStatus.Upcoming) -> + stringResource(R.string.video_premieres_at, formatPublishedDate(scheduledAt)) + else -> stringResource(R.string.video_views_hidden_by_provider) + } + val published = releaseTimeMillis()?.takeIf { scheduledAt == null }?.let(::formatPublishedDate) return listOfNotNull(views, published).takeIf { it.isNotEmpty() }?.joinToString(" · ") } -private fun formatViews(views: Long): String? = when { - views < 0L -> null +private fun formatViews(views: Long): String = when { views >= 1_000_000_000 -> "%.1fB".format(views / 1_000_000_000.0) views >= 1_000_000 -> "%.1fM".format(views / 1_000_000.0) views >= 1_000 -> "%.1fK".format(views / 1_000.0) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt index 13fc2333..57944b60 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamLoader.kt @@ -124,7 +124,9 @@ internal fun loadProgressiveStream( enriched = stream.withMetadataFrom(metadataStream) send(PlayerStreamUpdate.MetadataEnriched(enriched)) } - if (details != null && enriched.relatedStreams.isEmpty()) { + if (details != null && + (enriched.relatedStreams.isEmpty() || enriched.viewCount < 0L) + ) { for (delayMillis in RELATED_METADATA_RETRY_DELAYS) { retryDelay(delayMillis) val retry = loadMetadata() ?: break @@ -132,7 +134,7 @@ internal fun loadProgressiveStream( enriched = enriched.withMetadataFrom(metadataStream) send(PlayerStreamUpdate.MetadataEnriched(enriched)) } - if (enriched.relatedStreams.isNotEmpty()) break + if (enriched.relatedStreams.isNotEmpty() && enriched.viewCount >= 0L) break } } if (enriched.needsChannelMetadata()) { diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt index 84d773f2..2a0b3024 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt @@ -89,17 +89,19 @@ fun DescriptionSection( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - viewCount.knownCountOrNull()?.let { knownViews -> - val formattedViews = formatCompact(knownViews) - VideoStat( - value = formattedViews, - icon = Icons.Outlined.Visibility, - contentDescription = stringResource( + val knownViews = viewCount.knownCountOrNull() + val formattedViews = knownViews?.let(::formatCompact) + ?: stringResource(dev.typetype.android.R.string.video_views_hidden_by_provider) + VideoStat( + value = formattedViews, + icon = Icons.Outlined.Visibility, + contentDescription = knownViews?.let { + stringResource( dev.typetype.android.R.string.player_views_count, - formattedViews, - ), - ) - } + formatCompact(it), + ) + } ?: formattedViews, + ) if (likeCount > 0) { VideoStat( value = formatCompact(likeCount), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7ea06af..0f429046 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ %1$s views + Premieres %1$s + Views hidden by provider Welcome aboard Get started %1$s subscribers diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt index d51ae116..a24ebfe2 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamLoaderTest.kt @@ -170,7 +170,7 @@ class PlayerStreamLoaderTest { } else { listOf(relatedVideo()) } - Result.success(stream("Detailed", related = related)) + Result.success(stream("Detailed", related = related, viewCount = 42L)) }, loadProgress = { 0L }, retryDelay = {}, @@ -181,12 +181,37 @@ class PlayerStreamLoaderTest { assertEquals(listOf("related"), retried.stream.relatedStreams.map(Video::id)) } + @Test + fun `unknown bootstrap view count is recovered from metadata`() = runBlocking { + val metadataRequests = AtomicInteger() + val updates = loadProgressiveStream( + loadPlayback = { Result.success(stream("Bootstrap", viewCount = -1L)) }, + loadMetadata = { + val request = metadataRequests.incrementAndGet() + Result.success( + stream( + "Detailed", + viewCount = if (request == 1) -1L else 42L, + related = listOf(relatedVideo()), + ), + ) + }, + loadProgress = { 0L }, + retryDelay = {}, + ).take(3).toList() + + val recovered = updates[2] as PlayerStreamUpdate.MetadataEnriched + assertEquals(2, metadataRequests.get()) + assertEquals(42L, recovered.stream.viewCount) + } + private fun stream( title: String, accountId: String = "account", uploaderUrl: String = "", subscriberCount: Long = -1L, related: List