diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7af31729..91f1ce98 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 = 11005 - versionName = "1.10.1-beta.1" + versionCode = 11101 + versionName = "1.11.0-beta.1" 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 new file mode 100644 index 00000000..7ffd6f7b --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/DescriptionSectionCountTest.kt @@ -0,0 +1,51 @@ +package dev.typetype.android.feature.player.components + +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import dev.typetype.android.R +import org.junit.Rule +import org.junit.Test + +class DescriptionSectionCountTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun unknownViewCountIsHidden() { + setDescription(viewCount = -1) + + composeRule.onNodeWithText("-1").assertDoesNotExist() + composeRule.onNodeWithContentDescription(viewsLabel("-1")).assertDoesNotExist() + } + + @Test + fun zeroViewCountRemainsVisible() { + setDescription(viewCount = 0) + + composeRule.onNodeWithText("0").assertIsDisplayed() + composeRule.onNodeWithContentDescription(viewsLabel("0")).assertIsDisplayed() + } + + private fun setDescription(viewCount: Long) { + composeRule.setContent { + MaterialTheme { + DescriptionSection( + title = "Test video", + viewCount = viewCount, + likeCount = 0, + releaseDateMillis = 0, + description = "", + videoUrl = "https://www.youtube.com/watch?v=test", + onTimestampClick = {}, + ) + } + } + } + + 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/util/KnownCount.kt b/app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt new file mode 100644 index 00000000..a8b30314 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/util/KnownCount.kt @@ -0,0 +1,3 @@ +package dev.typetype.android.core.ui.util + +internal fun Long.knownCountOrNull(): Long? = takeIf { it >= 0L } diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt index da9f519b..eda2f364 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt @@ -49,6 +49,7 @@ import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.channel.Channel private val BannerAspectRatio = 16f / 6f @@ -150,12 +151,14 @@ private fun ChannelHeaderContent( ) Column(modifier = Modifier.weight(1f)) { ChannelName(channel, expanded) - Spacer(Modifier.height(2.dp)) - Text( - text = formatSubscribers(channel.subscriberCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + channel.subscriberCount.knownCountOrNull()?.let { subscriberCount -> + Spacer(Modifier.height(2.dp)) + Text( + text = formatSubscribers(subscriberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (notificationsAvailable) { IconButton( 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 b278f7ad..13fc2333 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 @@ -10,6 +10,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamRepository import javax.inject.Inject import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.launch @@ -96,6 +97,7 @@ internal fun loadProgressiveStream( loadChannelMetadata: suspend (Stream) -> Result? = { null }, loadProgress: suspend () -> Long, prefetchedMetadata: Stream? = null, + retryDelay: suspend (Long) -> Unit = { delay(it) }, ): Flow = channelFlow { val savedProgress = async { loadProgress() } val metadata = if (prefetchedMetadata == null) async { loadMetadata() } else null @@ -122,6 +124,17 @@ internal fun loadProgressiveStream( enriched = stream.withMetadataFrom(metadataStream) send(PlayerStreamUpdate.MetadataEnriched(enriched)) } + if (details != null && enriched.relatedStreams.isEmpty()) { + for (delayMillis in RELATED_METADATA_RETRY_DELAYS) { + retryDelay(delayMillis) + val retry = loadMetadata() ?: break + retry.onSuccess { metadataStream -> + enriched = enriched.withMetadataFrom(metadataStream) + send(PlayerStreamUpdate.MetadataEnriched(enriched)) + } + if (enriched.relatedStreams.isNotEmpty()) break + } + } if (enriched.needsChannelMetadata()) { loadChannelMetadata(enriched)?.onSuccess { channelStream -> send( @@ -156,3 +169,4 @@ private fun computeResumeMillis( private const val RESUME_MIN_MILLIS = 5_000L private const val RESUME_MAX_FRACTION = 0.95 +private val RELATED_METADATA_RETRY_DELAYS = longArrayOf(350L, 1_000L) 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 d362859f..84d773f2 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.compose.ui.unit.sp +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.navigation.sameVideoTimestampMillis import java.text.DateFormat import java.util.Date @@ -86,17 +87,20 @@ fun DescriptionSection( Spacer(Modifier.height(6.dp)) Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - VideoStat( - value = formatCompact(viewCount), - icon = Icons.Outlined.Visibility, - contentDescription = stringResource( - dev.typetype.android.R.string.player_views_count, - formatCompact(viewCount), - ), - ) + viewCount.knownCountOrNull()?.let { knownViews -> + val formattedViews = formatCompact(knownViews) + VideoStat( + value = formattedViews, + icon = Icons.Outlined.Visibility, + contentDescription = stringResource( + dev.typetype.android.R.string.player_views_count, + formattedViews, + ), + ) + } if (likeCount > 0) { - Spacer(Modifier.width(16.dp)) VideoStat( value = formatCompact(likeCount), icon = Icons.Outlined.ThumbUp, @@ -107,7 +111,6 @@ fun DescriptionSection( ) } releaseDate?.let { - Spacer(Modifier.width(16.dp)) VideoStat( value = it, icon = Icons.Outlined.Event, diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt index 5f560242..b7248e33 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt @@ -34,6 +34,7 @@ import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl +import dev.typetype.android.core.ui.util.knownCountOrNull import dev.typetype.android.domain.search.SearchChannel import dev.typetype.android.domain.search.SearchPlaylist import java.text.NumberFormat @@ -80,16 +81,17 @@ fun SearchChannelCard( } Text( text = when { - channel.subscriberCount >= 0 && channel.streamCount >= 0 -> stringResource( + channel.subscriberCount.knownCountOrNull() != null && + channel.streamCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_metadata, formatCount(channel.subscriberCount), formatCount(channel.streamCount), ) - channel.subscriberCount >= 0 -> stringResource( + channel.subscriberCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_metadata_subscribers_only, formatCount(channel.subscriberCount), ) - channel.streamCount >= 0 -> stringResource( + channel.streamCount.knownCountOrNull() != null -> stringResource( R.string.search_channel_videos_count, formatCount(channel.streamCount), ) @@ -118,7 +120,7 @@ fun SearchPlaylistCard( modifier: Modifier = Modifier, ) { val serverBaseUrl = LocalServerBaseUrl.current - val countLabel = displayablePlaylistStreamCount(playlist.streamCount)?.let { + val countLabel = playlist.streamCount.knownCountOrNull()?.let { stringResource(R.string.search_playlist_video_count, formatCount(it)) } ?: stringResource(R.string.search_playlist_type) Column( @@ -178,5 +180,3 @@ fun SearchPlaylistCard( } private fun formatCount(value: Long): String = NumberFormat.getIntegerInstance().format(value) - -internal fun displayablePlaylistStreamCount(value: Long): Long? = value.takeIf { it >= 0L } 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 89cab8ff..d51ae116 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 @@ -1,5 +1,6 @@ package dev.typetype.android.feature.player +import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamRequestScope @@ -157,11 +158,35 @@ class PlayerStreamLoaderTest { assertEquals(1, metadataRequests.get()) } + @Test + fun `empty bootstrap related videos trigger metadata retries`() = runBlocking { + val metadataRequests = AtomicInteger() + val updates = loadProgressiveStream( + loadPlayback = { Result.success(stream("Bootstrap")) }, + loadMetadata = { + val request = metadataRequests.incrementAndGet() + val related = if (request == 1) { + emptyList() + } else { + listOf(relatedVideo()) + } + Result.success(stream("Detailed", related = related)) + }, + loadProgress = { 0L }, + retryDelay = {}, + ).take(3).toList() + + val retried = updates[2] as PlayerStreamUpdate.MetadataEnriched + assertEquals(2, metadataRequests.get()) + assertEquals(listOf("related"), retried.stream.relatedStreams.map(Video::id)) + } + private fun stream( title: String, accountId: String = "account", uploaderUrl: String = "", subscriberCount: Long = -1L, + related: List