diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index b397537..56aa2de 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -78,7 +78,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private var subsInFlight = false var subsExhausted by mutableStateOf(false) private set - private var subsLoading = false + /** Exposed for the shimmer placeholders (issue #19). */ + var subsLoading = false + private set val recent = RecentVideosViewModel(repository) @@ -310,10 +312,14 @@ fun HomeRowsScreen( ) } item { - if (viewModel.continueItems.isEmpty()) { + if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) { + // Dummy cards with the faint shimmer while the row loads (#19); + // "Watch something first" only when there is genuinely no history. + ShimmerRow() + } else if (viewModel.continueItems.isEmpty()) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Text( - text = if (viewModel.continueLoading) "Loading…" else "Watch something first", + text = "Watch something first", style = MaterialTheme.typography.bodySmall, color = Color(0xFF8A8A8A), ) @@ -350,13 +356,7 @@ fun HomeRowsScreen( item { if (recentItems.isEmpty()) { LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } - Row(Modifier.padding(start = 16.dp)) { - Text( - text = "Loading…", - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), - ) - } + ShimmerRow() } else { FocusScrollRow( videos = recentItems, @@ -373,7 +373,7 @@ fun HomeRowsScreen( } } } - if (viewModel.subItems.isNotEmpty()) { + if (viewModel.subItems.isNotEmpty() || viewModel.subsLoading) { item { Text( text = "New from your subscriptions", @@ -383,6 +383,9 @@ fun HomeRowsScreen( ) } item { + if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { + ShimmerRow() + } else { FocusScrollRow( videos = viewModel.subItems, progressOf = { null }, @@ -396,10 +399,27 @@ fun HomeRowsScreen( LaunchedEffect(index) { viewModel.subsMore() } } } + } } } } } + +/** Placeholder row of dummy cards (issue #19). */ +@Composable +private fun ShimmerRow(size: Int = 4) { + Row(Modifier.padding(horizontal = 12.dp)) { + androidx.compose.foundation.layout.Row { + } + repeat(size) { + net.jeena.jtubetv.ui.screens.list.ShimmerCard( + modifier = Modifier + .padding(horizontal = 4.dp) + .width(rowCardWidth()), + ) + } + } +} /** * Horizontal card row with eased scrolling: when focus moves to a card outside the * viewport, the row animates it to the left edge (SmartTube behaviour) instead of diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index a080bed..a8e6593 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -554,9 +554,10 @@ private fun PlayerSurface( .fillMaxSize() .padding(end = if (sidebar != null) SIDEBAR_WIDTH else 0.dp), ) - // Cover the picture with the loading indicator until the player is really + // Cover the picture with a loading state until the player is really // ready to render — metadata resolution alone (the Loading state) is not - // enough (issue #8). + // enough (issue #8). Fullscreen loading shows the video's own thumbnail + // dimmed under the circle loader (issue #19) instead of plain black. if (buffering) { Box( Modifier @@ -564,7 +565,18 @@ private fun PlayerSurface( .background(Color.Black) .pointerInput(Unit) {}, // swallow taps/clicks while covering contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } + ) { + s.detail.thumbnailPath?.let { thumbPath -> + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(thumbPath), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box(Modifier.fillMaxSize().background(Color(0xB3000000))) + } + CircularProgressIndicator() + } } // Controls: SmartTube-style (title + channel above, then the seek bar + // action row). Persistent when paused, transient while playing. diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 0675655..ccd3802 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -4,6 +4,17 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.foundation.layout.size import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.LinearEasing +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.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.drawBehind import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio @@ -266,4 +277,57 @@ internal fun formatAge(iso: String?): String { } catch (_: DateTimeParseException) { "" } -} \ No newline at end of file +} +/** + * Dummy card while a row/grid loads: gray rectangles in the card's shape with + * a very faint sweeping sheen instead of a spinner (issue #19 — the spinner + * says nothing about what's coming). + */ +@Composable +fun ShimmerCard(modifier: Modifier = Modifier) { + val sweep = rememberInfiniteTransition() + val x by sweep.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)), + ) + val base = Color(0xFF232323) + Column(modifier) { + Box( + Modifier + .fillMaxWidth() + .height(158.dp) + .background(base, RoundedCornerShape(8.dp)) + .shimmer(x), + ) + Box( + Modifier + .padding(top = 10.dp) + .height(13.dp) + .fillMaxWidth(0.85f) + .background(base, RoundedCornerShape(4.dp)) + .shimmer(x), + ) + Box( + Modifier + .padding(top = 6.dp) + .height(10.dp) + .fillMaxWidth(0.55f) + .background(base, RoundedCornerShape(4.dp)) + .shimmer(x), + ) + } +} + +/** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ +fun Modifier.shimmer(progress: Float): Modifier = drawBehind { + drawRect( + Brush.linearGradient( + 0f to Color.Transparent, + 0.5f to Color(0x14FFFFFF), + 1f to Color.Transparent, + start = Offset(size.width * (progress * 2f - 1f), size.height), + end = Offset(size.width * (progress * 2f), 0f), + ), + ) +}