feat(android,player): an up-next card with the next still and a countdown ring

The card shows the next episode's still, or Proton's thumbnail of it, with
a ring over it that runs down to the switch and plays it at once on a tap.
The provider's episode name heads it where there is one. "Watch till the
end" stays under it.
This commit is contained in:
nvrl committed 2026-10-03 05:18:30 +02:00
1 parent cfd654b267
commit 6229a59561
1 file changed
+61 -16
@@ -84,6 +84,13 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.thumbnailSource
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import io.narl.protonstream.ui.RemoteArtwork
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.draw.clip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -905,26 +912,64 @@ private fun UpNextCard(
}
// `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return
// Eased between ticks, so the ring runs down rather than stepping once a
// second.
val ring by animateFloatAsState(
targetValue = remaining.toFloat() / UP_NEXT_SECONDS,
animationSpec = tween(durationMillis = 1_000, easing = LinearEasing),
label = "up-next countdown",
)
Column(
modifier
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.width(280.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
.padding(12.dp),
) {
Text(
"Up next in ${remaining}s",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.label,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
AccentButton(onClick = onPlayNow) { Text("Play now") }
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
Row(verticalAlignment = Alignment.CenterVertically) {
// The next episode's still, so the card says which episode
// without being read; the ring over it is both the countdown and
// the button that skips it.
Box(
Modifier
.width(112.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onPlayNow),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
upNext.stillUrl,
upNext.label,
Modifier.fillMaxSize(),
fallback = upNext.thumbnailSource,
)
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
CircularProgressIndicator(
progress = { ring },
modifier = Modifier.size(40.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.25f),
strokeWidth = 3.dp,
)
Icon(Icons.Default.PlayArrow, contentDescription = "Play now", tint = Color.White)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(
"Up next in ${remaining}s",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.providerName ?: upNext.label,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
}
}
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
Text("Watch till the end")
}
}
}