Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

@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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package dev.typetype.android.core.ui.util

internal fun Long.knownCountOrNull(): Long? = takeIf { it >= 0L }
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,6 +97,7 @@ internal fun loadProgressiveStream(
loadChannelMetadata: suspend (Stream) -> Result<Stream>? = { null },
loadProgress: suspend () -> Long,
prefetchedMetadata: Stream? = null,
retryDelay: suspend (Long) -> Unit = { delay(it) },
): Flow<PlayerStreamUpdate> = channelFlow {
val savedProgress = async { loadProgress() }
val metadata = if (prefetchedMetadata == null) async { loadMetadata() } else null
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -107,7 +111,6 @@ fun DescriptionSection(
)
}
releaseDate?.let {
Spacer(Modifier.width(16.dp))
VideoStat(
value = it,
icon = Icons.Outlined.Event,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 }
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Video> = emptyList(),
) = Stream(
playbackContract = StreamPlaybackContract.ServerSabr,
id = "video",
Expand All @@ -185,5 +210,23 @@ class PlayerStreamLoaderTest {
serverHlsManifestUrl = null,
requestScope = StreamRequestScope("server", accountId, "https://instance.example/api/"),
startPositionMillis = 0L,
relatedStreams = related,
)

private fun relatedVideo() = Video(
id = "related",
url = "https://www.youtube.com/watch?v=related",
title = "Related",
thumbnailUrl = "",
uploaderName = "Channel",
uploaderUrl = "",
uploaderAvatarUrl = "",
uploaderVerified = false,
durationSeconds = 60L,
isLive = false,
viewCount = 1L,
uploadedAtMillis = -1L,
isShortFormContent = false,
shortDescription = null,
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package dev.typetype.android.feature.search

import dev.typetype.android.core.ui.util.knownCountOrNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class SearchPlaylistPresentationTest {
@Test
fun `negative upstream sentinel is not presented as a video count`() {
assertNull(displayablePlaylistStreamCount(-2))
assertNull((-2L).knownCountOrNull())
}

@Test
fun `known playlist count remains visible`() {
assertEquals(12L, displayablePlaylistStreamCount(12))
assertEquals(12L, 12L.knownCountOrNull())
}

@Test
fun `zero remains a known count`() {
assertEquals(0L, 0L.knownCountOrNull())
}
}
5 changes: 5 additions & 0 deletions release-notes/v1.11.0-beta.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
TypeType Android 1.11.0-beta.1

- Hide unknown view, subscriber, and channel video counts instead of displaying -1.
- Keep real zero counts visible.
- Retry metadata when prewarmed playback returns no related videos, so recommendations appear reliably below the player.