From ca234252ca637dbeedd7752826828f56ab0e4a5e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:12:32 +0200 Subject: [PATCH 1/7] feat: add skeleton placeholder primitives --- .../core/ui/components/TypeTypeSkeleton.kt | 85 +++++++++++++++++++ .../player/components/CommentSkeleton.kt | 44 ++-------- 2 files changed, 94 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt new file mode 100644 index 00000000..01ede4b2 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt @@ -0,0 +1,85 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +internal fun skeletonPulseAlpha(): Float { + val transition = rememberInfiniteTransition(label = "skeleton") + val alpha by transition.animateFloat( + initialValue = SKELETON_MIN_ALPHA, + targetValue = SKELETON_MAX_ALPHA, + animationSpec = infiniteRepeatable( + animation = tween(SKELETON_PULSE_MILLIS), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton alpha", + ) + return alpha +} + +@Composable +internal fun TypeTypeSkeleton( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(SKELETON_CORNER), + alpha: Float = skeletonPulseAlpha(), +) { + Spacer( + modifier = modifier + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .graphicsLayer { this.alpha = alpha }, + ) +} + +@Composable +internal fun TypeTypeSkeletonLine( + widthFraction: Float, + height: Dp = 10.dp, + modifier: Modifier = Modifier, + alpha: Float = skeletonPulseAlpha(), +) { + TypeTypeSkeleton( + modifier = modifier.fillMaxWidth(widthFraction).height(height), + alpha = alpha, + ) +} + +@Composable +internal fun TypeTypeSkeletonCircle( + size: Dp, + modifier: Modifier = Modifier, + alpha: Float = skeletonPulseAlpha(), +) { + TypeTypeSkeleton( + modifier = modifier.size(size), + shape = CircleShape, + alpha = alpha, + ) +} + +internal val SkeletonCardShape = RoundedCornerShape(12.dp) + +private const val SKELETON_CORNER = 4 +private const val SKELETON_MIN_ALPHA = 0.38f +private const val SKELETON_MAX_ALPHA = 0.72f +private const val SKELETON_PULSE_MILLIS = 850 diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt index 4ffeb1f8..33e025b9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentSkeleton.kt @@ -1,11 +1,5 @@ package dev.typetype.android.feature.player.components -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,47 +7,27 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.typetype.android.core.ui.components.TypeTypeSkeletonCircle +import dev.typetype.android.core.ui.components.TypeTypeSkeletonLine +import dev.typetype.android.core.ui.components.skeletonPulseAlpha @Composable internal fun CommentSkeleton(avatarSize: Dp = 36.dp) { - val transition = rememberInfiniteTransition(label = "comment skeleton") - val alpha by transition.animateFloat( - initialValue = 0.38f, - targetValue = 0.72f, - animationSpec = infiniteRepeatable( - animation = tween(850), - repeatMode = RepeatMode.Reverse, - ), - label = "comment skeleton alpha", - ) - val color = MaterialTheme.colorScheme.surfaceVariant - Row(modifier = Modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }) { - Spacer(Modifier.size(avatarSize).background(color, CircleShape)) + val alpha = skeletonPulseAlpha() + Row(modifier = Modifier.fillMaxWidth()) { + TypeTypeSkeletonCircle(size = avatarSize, alpha = alpha) Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { - Spacer( - Modifier.fillMaxWidth(0.38f).height(12.dp) - .background(color, RoundedCornerShape(4.dp)), - ) + TypeTypeSkeletonLine(widthFraction = 0.38f, height = 12.dp, alpha = alpha) Spacer(Modifier.height(10.dp)) - Spacer( - Modifier.fillMaxWidth().height(10.dp) - .background(color, RoundedCornerShape(4.dp)), - ) + TypeTypeSkeletonLine(widthFraction = 1f, alpha = alpha) Spacer(Modifier.height(6.dp)) - Spacer( - Modifier.fillMaxWidth(0.7f).height(10.dp) - .background(color, RoundedCornerShape(4.dp)), - ) + TypeTypeSkeletonLine(widthFraction = 0.7f, alpha = alpha) } } } From 1b0f8a573886e18b3c145a4bef7865010a446e5e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:18:51 +0200 Subject: [PATCH 2/7] feat: retry channel avatars while they load --- .../core/ui/components/ChannelAvatar.kt | 105 ++++++++++++++++++ .../core/ui/components/RelatedVideoCard.kt | 14 +-- .../android/core/ui/components/VideoCard.kt | 11 +- .../feature/player/components/UploaderCard.kt | 16 +-- .../core/ui/components/ChannelAvatarTest.kt | 46 ++++++++ 5 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt create mode 100644 app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt new file mode 100644 index 00000000..978b3827 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt @@ -0,0 +1,105 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl +import kotlinx.coroutines.delay + +@Composable +fun ChannelAvatar( + avatarUrl: String, + name: String, + size: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val serverBaseUrl = LocalServerBaseUrl.current + val context = LocalPlatformContext.current + var attempt by remember(avatarUrl) { mutableIntStateOf(0) } + var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) } + val hasSource = avatarUrl.isNotBlank() + var missingExpired by remember(avatarUrl) { mutableStateOf(false) } + + LaunchedEffect(avatarUrl, hasSource) { + if (hasSource) { + missingExpired = false + return@LaunchedEffect + } + missingExpired = false + delay(MISSING_AVATAR_GRACE_MILLIS) + missingExpired = true + } + + val missing = !hasSource && !missingExpired + val loading = missing || (hasSource && !loaded) + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center, + ) { + if (loading) { + TypeTypeSkeletonCircle(size = size) + } else { + Text( + text = avatarInitial(name), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (hasSource && !loaded) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt)) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + onSuccess = { loaded = true }, + onError = { + if (attempt < MAX_AVATAR_RETRIES) attempt += 1 + }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +internal fun avatarRetryUrl(url: String, attempt: Int): String { + if (attempt <= 0 || url.isBlank()) return url + val separator = if (url.contains('?')) '&' else '?' + return "$url$separator$AVATAR_RETRY_PARAM=$attempt" +} + +internal fun avatarInitial(name: String): String = + name.trim().firstOrNull()?.uppercase() ?: "?" + +private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry" +private const val MAX_AVATAR_RETRIES = 2 +private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L 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 88faaba4..c1f22c15 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -124,18 +123,15 @@ fun RelatedVideoCard( overflow = TextOverflow.Ellipsis, ) Row(verticalAlignment = Alignment.CenterVertically) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), + ChannelAvatar( + avatarUrl = video.uploaderAvatarUrl, + name = video.uploaderName, + size = 18.dp, contentDescription = stringResource( R.string.video_open_channel_accessibility, video.uploaderName, ), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(18.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .combinedClickable(onClick = onChannelClick, role = Role.Button), + modifier = Modifier.combinedClickable(onClick = onChannelClick, role = Role.Button), ) Spacer(Modifier.width(6.dp)) Text( 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 3bf674d7..ba417bba 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Icon @@ -130,9 +129,6 @@ fun VideoCard( Spacer(Modifier.height(10.dp)) Row(verticalAlignment = Alignment.Top) { val avatarModifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) .let { if (onChannelClick != null) { it.combinedClickable(onClick = onChannelClick, role = Role.Button) @@ -140,14 +136,15 @@ fun VideoCard( it } } - AsyncImage( - model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), + ChannelAvatar( + avatarUrl = video.uploaderAvatarUrl, + name = video.uploaderName, + size = 36.dp, contentDescription = if (onChannelClick != null) { stringResource(R.string.video_open_channel_accessibility, video.uploaderName) } else { null }, - contentScale = ContentScale.Crop, modifier = avatarModifier, ) Spacer(Modifier.width(10.dp)) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt index 13e00664..7621bdbd 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -23,13 +22,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.components.ChannelAvatar import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl @@ -50,14 +48,10 @@ fun UploaderCard( modifier = modifier.fillMaxWidth().clickable(onClick = onCardClick), verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, avatarUrl), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), + ChannelAvatar( + avatarUrl = avatarUrl, + name = name, + size = 40.dp, ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt b/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt new file mode 100644 index 00000000..f34d55e4 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/core/ui/components/ChannelAvatarTest.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.core.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChannelAvatarTest { + @Test + fun `first attempt uses the original url`() { + assertEquals( + "https://example.test/avatar.png", + avatarRetryUrl("https://example.test/avatar.png", 0), + ) + } + + @Test + fun `retries append a distinct cache key`() { + assertEquals( + "https://example.test/avatar.png?_tt_avatar_retry=1", + avatarRetryUrl("https://example.test/avatar.png", 1), + ) + assertEquals( + "https://example.test/avatar.png?_tt_avatar_retry=2", + avatarRetryUrl("https://example.test/avatar.png", 2), + ) + } + + @Test + fun `retries keep existing query parameters`() { + assertEquals( + "https://example.test/proxy?url=abc&_tt_avatar_retry=1", + avatarRetryUrl("https://example.test/proxy?url=abc", 1), + ) + } + + @Test + fun `blank urls are left untouched`() { + assertEquals("", avatarRetryUrl("", 2)) + assertEquals(" ", avatarRetryUrl(" ", 2)) + } + + @Test + fun `initial falls back to a question mark`() { + assertEquals("C", avatarInitial("CodeYure")) + assertEquals("?", avatarInitial(" ")) + } +} From 0a3f473fba5e23d2d66773a9bda90191da1b8198 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:25:29 +0200 Subject: [PATCH 3/7] feat: show card placeholders while feeds load --- .../core/ui/components/VideoCardSkeleton.kt | 98 +++++++++++++++++++ .../android/feature/home/HomeScreen.kt | 4 +- .../android/feature/search/SearchScreen.kt | 13 +-- .../subscriptions/SubscriptionsLoadingGrid.kt | 55 +---------- 4 files changed, 104 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt new file mode 100644 index 00000000..9fe547a1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCardSkeleton.kt @@ -0,0 +1,98 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Composable +internal fun VideoCardSkeleton() { + val alpha = skeletonPulseAlpha() + Column(modifier = Modifier.fillMaxWidth()) { + TypeTypeSkeleton( + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + shape = SkeletonCardShape, + alpha = alpha, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TypeTypeSkeletonCircle(size = 36.dp, alpha = alpha) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + TypeTypeSkeletonLine(widthFraction = 1f, height = 12.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.72f, height = 12.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.45f, alpha = alpha) + } + } + } +} + +@Composable +internal fun RelatedVideoCardSkeleton(thumbnailWidth: Dp = 148.dp) { + val alpha = skeletonPulseAlpha() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + TypeTypeSkeleton( + modifier = Modifier.width(thumbnailWidth).aspectRatio(16f / 9f), + shape = SkeletonCardShape, + alpha = alpha, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + TypeTypeSkeletonLine(widthFraction = 1f, height = 10.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.75f, height = 10.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.34f, height = 10.dp, alpha = alpha) + } + } +} + +@Composable +fun VideoGridSkeleton( + modifier: Modifier = Modifier, + count: Int = DEFAULT_SKELETON_CARDS, + minCellWidth: Dp = 280.dp, + horizontalPadding: Dp = 16.dp, + verticalPadding: Dp = 16.dp, + verticalSpacing: Dp = 24.dp, + contentDescription: String? = null, +) { + val description = contentDescription ?: stringResource(R.string.state_loading) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = minCellWidth), + modifier = modifier + .fillMaxSize() + .semantics { this.contentDescription = description }, + contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(verticalSpacing), + userScrollEnabled = false, + ) { + items(SKELETON_CARD_SLOTS.take(count), key = { it }) { VideoCardSkeleton() } + } +} + +private const val DEFAULT_SKELETON_CARDS = 6 +private val SKELETON_CARD_SLOTS = (0 until 12).toList() diff --git a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt index fbacd11a..35db331a 100644 --- a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R import dev.typetype.android.core.ui.components.AnimatedError -import dev.typetype.android.core.ui.components.FullScreenLoader +import dev.typetype.android.core.ui.components.VideoGridSkeleton import dev.typetype.android.core.ui.components.LazyPaginationFooter import dev.typetype.android.core.ui.components.SectionHeader import dev.typetype.android.core.ui.components.VideoCard @@ -77,7 +77,7 @@ internal fun HomeContent( val continueWatching = if (state.hideContinueWatching) emptyList() else state.continueWatching val showRecommendations = !state.hideHomeRecommendations when { - state.isLoading && state.videos.isEmpty() && continueWatching.isEmpty() -> FullScreenLoader() + state.isLoading && state.videos.isEmpty() && continueWatching.isEmpty() -> VideoGridSkeleton() state.errorMessage != null && state.videos.isEmpty() && continueWatching.isEmpty() -> AnimatedError( message = state.errorMessage, requestId = state.errorRequestId, diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt index 08ce4aa5..a5ff89f5 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.NorthWest import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -52,6 +51,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R import dev.typetype.android.core.ui.components.AnimatedError +import dev.typetype.android.core.ui.components.VideoGridSkeleton @Composable fun SearchRoute( @@ -125,16 +125,7 @@ fun SearchScreen( } when { - state.isLoading -> Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - val description = stringResource(R.string.state_loading) - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { contentDescription = description }, - ) - } + state.isLoading -> VideoGridSkeleton(verticalPadding = 12.dp) state.errorMessage != null -> AnimatedError( message = state.errorMessage, requestId = state.errorRequestId, diff --git a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt index 61dd74cb..aad65d75 100644 --- a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt +++ b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt @@ -1,48 +1,22 @@ package dev.typetype.android.feature.subscriptions -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import dev.typetype.android.R +import dev.typetype.android.core.ui.components.VideoCardSkeleton @Composable internal fun SubscriptionsLoadingGrid() { - val transition = rememberInfiniteTransition(label = "subscriptions-loading") - val alpha by transition.animateFloat( - initialValue = 0.45f, - targetValue = 0.85f, - animationSpec = infiniteRepeatable(tween(750), RepeatMode.Reverse), - label = "subscriptions-loading-alpha", - ) val description = stringResource(R.string.subscriptions_preparing) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 320.dp), @@ -52,31 +26,6 @@ internal fun SubscriptionsLoadingGrid() { verticalArrangement = Arrangement.spacedBy(24.dp), userScrollEnabled = false, ) { - items((0 until 6).toList()) { - LoadingCard(alpha) - } - } -} - -@Composable -private fun LoadingCard(alpha: Float) { - val color = MaterialTheme.colorScheme.surfaceVariant - Column(modifier = Modifier.fillMaxWidth().alpha(alpha)) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .clip(MaterialTheme.shapes.medium) - .background(color), - ) - Spacer(Modifier.height(10.dp)) - Row { - Box(Modifier.size(36.dp).clip(CircleShape).background(color)) - Spacer(Modifier.width(10.dp)) - Column(verticalArrangement = Arrangement.spacedBy(7.dp), modifier = Modifier.weight(1f)) { - Box(Modifier.fillMaxWidth(0.88f).height(14.dp).clip(CircleShape).background(color)) - Box(Modifier.fillMaxWidth(0.55f).height(10.dp).clip(CircleShape).background(color)) - } - } + items((0 until 6).toList(), key = { it }) { VideoCardSkeleton() } } } From 0dc72018bd2d1e3ec913db507c58db69fbb26d81 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:30:08 +0200 Subject: [PATCH 4/7] feat: show player details placeholders while loading --- .../feature/player/PlayerLoadingState.kt | 5 +- .../components/PlayerDetailsSkeleton.kt | 88 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt index 8c37e2cf..4b32cce2 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerLoadingState.kt @@ -12,18 +12,20 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import dev.typetype.android.core.ui.components.AnimatedLoader +import dev.typetype.android.feature.player.components.PlayerDetailsSkeleton @Composable fun LoadingState() { Column( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), ) { Box( @@ -35,5 +37,6 @@ fun LoadingState() { ) { AnimatedLoader(size = 88.dp) } + PlayerDetailsSkeleton() } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt new file mode 100644 index 00000000..e9ac3a17 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDetailsSkeleton.kt @@ -0,0 +1,88 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.typetype.android.core.ui.components.TypeTypeSkeleton +import dev.typetype.android.core.ui.components.TypeTypeSkeletonCircle +import dev.typetype.android.core.ui.components.TypeTypeSkeletonLine +import dev.typetype.android.core.ui.components.skeletonPulseAlpha + +@Composable +internal fun PlayerDetailsSkeleton(modifier: Modifier = Modifier) { + val alpha = skeletonPulseAlpha() + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TitleSkeleton(alpha) + UploaderSkeleton(alpha) + ActionRowSkeleton(alpha) + DescriptionSkeleton(alpha) + } +} + +@Composable +private fun TitleSkeleton(alpha: Float) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TypeTypeSkeletonLine(widthFraction = 1f, height = 18.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.72f, height = 18.dp, alpha = alpha) + } +} + +@Composable +private fun UploaderSkeleton(alpha: Float) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TypeTypeSkeletonCircle(size = 40.dp, alpha = alpha) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + TypeTypeSkeletonLine(widthFraction = 0.42f, height = 12.dp, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.28f, alpha = alpha) + } + TypeTypeSkeleton( + modifier = Modifier.width(104.dp).height(40.dp), + alpha = alpha, + ) + } +} + +@Composable +private fun ActionRowSkeleton(alpha: Float) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(PLAYER_ACTION_PLACEHOLDERS) { + TypeTypeSkeletonCircle(size = 48.dp, alpha = alpha) + } + } +} + +@Composable +private fun DescriptionSkeleton(alpha: Float) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TypeTypeSkeletonLine(widthFraction = 1f, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.86f, alpha = alpha) + TypeTypeSkeletonLine(widthFraction = 0.52f, alpha = alpha) + } +} + +private const val PLAYER_ACTION_PLACEHOLDERS = 6 From a748d76cae79852cd835f95636715f2d03cc2db7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:36:19 +0200 Subject: [PATCH 5/7] fix: show home card placeholders while recommendations load --- .../dev/typetype/android/feature/home/HomeScreen.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt index 35db331a..cb07101b 100644 --- a/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/home/HomeScreen.kt @@ -26,6 +26,7 @@ import dev.typetype.android.core.ui.components.VideoGridSkeleton import dev.typetype.android.core.ui.components.LazyPaginationFooter import dev.typetype.android.core.ui.components.SectionHeader import dev.typetype.android.core.ui.components.VideoCard +import dev.typetype.android.core.ui.components.VideoCardSkeleton import dev.typetype.android.feature.menu.VideoMenuScope import dev.typetype.android.feature.menu.rememberVideoMenuScope @@ -100,7 +101,7 @@ internal fun HomeContent( ) } } - if (state.isLoading && showRecommendations) { + if (state.isLoading && showRecommendations && visibleVideos.isNotEmpty()) { item(key = "home-refresh", span = { GridItemSpan(maxLineSpan) }) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } @@ -117,6 +118,11 @@ internal fun HomeContent( modifier = Modifier.padding(horizontal = 4.dp), ) } + if (state.isLoading && visibleVideos.isEmpty()) { + items(HOME_SKELETON_SLOTS, key = { "home-skeleton-$it" }) { + VideoCardSkeleton() + } + } items( visibleVideos, key = { "home-${it.id}" }, @@ -156,3 +162,5 @@ private fun HomeEmptyState() { ) } } + +private val HOME_SKELETON_SLOTS = (0 until 6).toList() From bc5bab6c6f77e0e05bbd211e164c993f37fe4275 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:53:55 +0200 Subject: [PATCH 6/7] ci: publish beta release notes from the notes folder --- .github/workflows/android-beta.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml index 04c0b819..a544a1a5 100644 --- a/.github/workflows/android-beta.yml +++ b/.github/workflows/android-beta.yml @@ -183,13 +183,24 @@ jobs: compression-level: 0 retention-days: 30 + - name: Prepare beta release notes + if: ${{ github.event_name != 'pull_request' }} + env: + HEAD_MESSAGE: ${{ github.event.head_commit.message }} + run: | + if [ -f "release-notes/${RELEASE_TAG}.md" ]; then + cp "release-notes/${RELEASE_TAG}.md" release-body.md + else + printf '%s\n' "${HEAD_MESSAGE:-Beta build from the development branch.}" > release-body.md + fi + - name: Publish GitHub prerelease if: ${{ github.event_name != 'pull_request' }} uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.RELEASE_TAG }} name: TypeType Android ${{ env.RELEASE_VERSION }} - body: ${{ github.event.head_commit.message }} + body_path: release-body.md prerelease: true make_latest: false generate_release_notes: false From e2b11f27445b378221cce2f65249401cc6be9b12 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 09:54:17 +0200 Subject: [PATCH 7/7] build: publish mobile beta 1.9.0-beta.1 --- app/build.gradle.kts | 4 +- release-notes/v1.9.0-beta.1.md | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.9.0-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad3b3ab4..fdc5aadf 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 = 10823 - versionName = "1.8.1-beta.2" + versionCode = 10824 + versionName = "1.9.0-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.9.0-beta.1.md b/release-notes/v1.9.0-beta.1.md new file mode 100644 index 00000000..b47ba74f --- /dev/null +++ b/release-notes/v1.9.0-beta.1.md @@ -0,0 +1,69 @@ +# TypeType for Android 1.9.0-beta.1 + +TypeType for Android 1.9.0-beta.1 fixes provider playback, brings subscription notifications that also work without a push distributor, adds previous and next video controls, and aligns the loading placeholders with the web client. + +This is a beta build on the 1.9 line. + +## Playback and providers + +- Play YouTube lives from the server live manifest, so an active live starts instead of failing on a missing SABR contract. +- Fall back to the SABR stream route when the SABR bootstrap cannot serve a video. +- Play Niconico and BiliBili provider media handles, including the playback container and the height read from the resolution label, and keep merged video and audio working. +- Report typed provider failures as unavailable content instead of a generic playback error. +- Label scheduled lives as not started instead of reporting a delivery failure. +- Remove the thick black bar above the player during the pull-down gesture. [#50](https://github.com/TypeType-Video/TypeType-Android/issues/50) + +## Player controls + +- Add previous and next video controls to the player. [#87](https://github.com/TypeType-Video/TypeType-Android/issues/87) +- Keep the favorite and watch later state in the player synchronized with the library. + +## Loading placeholders + +- Show card placeholders while the home, search, and subscription feeds load, instead of a blank area or a lone spinner. +- Show details placeholders while a video loads, covering the title, channel, subscribe button, actions, and description. +- Show channel avatars with a placeholder while they load, and retry a failed avatar up to two times before falling back to the channel initial. + +## Bullet comments + +- Enable bullet comments on BiliBili, matching the web client, in addition to NicoNico. + +## Notifications + +- Add subscription notifications with UnifiedPush, a channel bell, and a dedicated notification settings screen. [#51](https://github.com/TypeType-Video/TypeType-Android/issues/51) +- Persist the push capability with subscriptions so the bell appears on subscribed channels. [#86](https://github.com/TypeType-Video/TypeType-Android/issues/86) +- Deliver subscription notifications locally every fifteen minutes, including when UnifiedPush is registered, so a silent push no longer means no notification. + +## Appearance + +- Grey out the accent color picker when the Dynamic theme is selected, since the system owns the accent in that mode. [#72](https://github.com/TypeType-Video/TypeType-Android/issues/72) + +## Performance + +- Prewarm SABR playback when a video card, an autoplay preload, or a Shorts preload is shown, with serialized requests. +- Skip SABR prewarm for live videos, which previously triggered a full extraction that was then discarded. + +## Compatibility + +- Keep Android 6.0 through Android 17 support, API 23 through API 37. +- Fix a crash on Android 6.0 caused by the JVM URL encoder. + +## Thx + +Thx to @therealresonix for the player gesture, fullscreen, and appearance reports. [#50](https://github.com/TypeType-Video/TypeType-Android/issues/50) [#72](https://github.com/TypeType-Video/TypeType-Android/issues/72) + +Thx to @ollieisonline for the notification bell and push notification reports. [#51](https://github.com/TypeType-Video/TypeType-Android/issues/51) [#86](https://github.com/TypeType-Video/TypeType-Android/issues/86) + +Thx to @303inmyheart for the player navigation report. [#87](https://github.com/TypeType-Video/TypeType-Android/issues/87) + +Thx to @filippobaroni for the comment rendering report. [#82](https://github.com/TypeType-Video/TypeType-Android/issues/82) + +Thx as well to everyone testing the beta builds, sharing diagnostics, and helping TypeType Android improve. + +## Installing + +Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +The stable and beta F-Droid channels use the same application identity, so switching channels updates the existing installation. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.8.1-beta.2...v1.9.0-beta.1