From 3d4de90671e21c2616f2601669fe87ba582aaf59 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 08:29:43 +0000 Subject: [PATCH] player: Align the controls overlay; consistent stat icons The overlay's spacing came out lopsided: 34dp between the seek bar and the action row while the title/channel line sat tight, the first pill's glyph ~50dp from the screen edge vs the 24dp-aligned text behind it, and the time text at a different right margin than the track it belongs to. Positioning recipe (measured on the emulator, cross-checked with a second model): seek-row box 28dp with no horizontal padding so the track spans the same 24dp margins as the text, action row 6dp below it, first pill drops its start padding so the glyph aligns flush with the text margin, time padded 3dp so its right edge tracks the rounded cap. The stat icons now match the Channels view (Groups = subscribers, PlayArrow = views next to the numbers), the overlay's subscribe became Groups instead of the YouTube-notifications bell (the bell read as notifications), and federated channels without an avatar get a letter-circle fallback so the channel row keeps its shape. --- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 5 +- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 100 +++++++++++++++--- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 86d8916..44961b0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -37,6 +37,8 @@ data class VideoChannelSummary( @SerialName("displayName") val displayName: String, val host: String, val avatars: List = emptyList(), + // Present on video detail responses — used for the subscriber count. + @SerialName("followersCount") val followersCount: Long? = null, ) @Serializable @@ -156,7 +158,8 @@ data class InstanceInfoDto( val shortDescription: String? = null, ) -/** Per-channel stats not available in the listing; fetched on demand. */ +/** Per-channel stats not available in the listing; fetched on demand. Snapshotable. */ +@Serializable data class ChannelStats( val videos: Long, val plays: Long, 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 07b3c7f..dc9e192 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 @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer 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.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.Replay10 @@ -657,30 +659,60 @@ private fun PausedControls( .focusable() .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = detail.channel.avatars.maxByOrNull { it.width } - if (avatar != null) { + val avatarUrl = detail.channel.avatars.maxByOrNull { it.width }?.let { + it.fileUrl ?: AppGraph.repository.absoluteUrl(it.path) + } + if (avatarUrl != null) { coil.compose.AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + model = avatarUrl, contentDescription = "Open channel", contentScale = ContentScale.Crop, modifier = channelModifier .clip(CircleShape), ) + } else { + // Federated mirrors often ship without an avatar: draw a + // letter-circle so the row keeps its shape. + Box( + modifier = channelModifier + .clip(CircleShape) + .background(avatarColorFor(detail.channel.displayName)), + contentAlignment = Alignment.Center, + ) { + Text( + text = detail.channel.displayName.take(1).uppercase(), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + } } - Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) { + Column(modifier = Modifier.padding(start = 14.dp)) { Text( text = detail.name, color = Color.White, style = MaterialTheme.typography.titleLarge, maxLines = 2, ) - Text( - text = detail.channel.displayName + " · " + formatViews(detail.views) + - " · " + formatPublished(detail.publishedAt), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 2.dp), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = detail.channel.displayName, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + // Same stat icons as the Channels view: Groups = subscribers, + // PlayArrow = views. + detail.channel.followersCount?.let { followers -> + InlineStat(Icons.Filled.Groups, followers) + InlineStat(Icons.Filled.PlayArrow, detail.views) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = formatPublished(detail.publishedAt), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } } } // The seek bar is the only playback control: focused = scrub, OK toggles. @@ -710,13 +742,14 @@ private fun PausedControls( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 10.dp), + .padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerActionButton( - icon = if (subscribed == true) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsNone, + icon = Icons.Filled.Groups, desc = if (subscribed == true) "Subscribed" else "Subscribe", filled = subscribed == true, + flushLeft = true, focusRequester = actionBarFocus, ) { done -> scope.launch { @@ -789,6 +822,7 @@ private fun PausedControls( formatDuration((durationMs / 1000).toInt()), color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 3.dp), ) } } @@ -912,7 +946,35 @@ private fun formatDuration(seconds: Int): String { } } -/** Pill button of the video action row (issue #10): text or emoji-labelled; the +/** Icon + number inline in the channel line, styled like the Channels view stats. */ +@Composable +private fun InlineStat(icon: androidx.compose.ui.graphics.vector.ImageVector, value: Long) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = Color(0xFF9A9A9A), + modifier = Modifier + .padding(start = 14.dp) + .size(18.dp), + ) + Text( + text = value.toString(), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) +} + +/** Stable pleasant color for a letter-avatar fallback. */ +private fun avatarColorFor(name: String): Color { + val palette = listOf( + 0xFFB3554D, 0xFF3E7A82, 0xFF7A5F9E, 0xFF5B8C51, 0xFF9E7B45, + 0xFF4D6FB3, 0xFFA85C82, 0xFF6B8E4E, + ) + return Color(palette[(name.hashCode().let { if (it < 0) -it else it } % palette.size)]) +} + +/** Pill button of the video action row (issue #10): icon-only (no strings to translate); * subscribed/liked states get a solid accent background. */ @Composable private fun PlayerActionButton( @@ -920,6 +982,9 @@ private fun PlayerActionButton( desc: String, count: Long? = null, filled: Boolean = false, + // First pill in the row: drop the left inner padding so the glyph aligns + // flush with the 24dp text margin. + flushLeft: Boolean = false, focusRequester: FocusRequester? = null, // [onClick] receives an onDone callback; only call it when the action's // network roundtrip finished — events arriving before onDone are dropped. @@ -958,7 +1023,12 @@ private fun PlayerActionButton( // Click only for real pointer input; the key activation above is guarded. .pointerInput(Unit) { detectTapGestures { activate() } } Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.padding( + start = if (flushLeft) 0.dp else 12.dp, + top = 8.dp, + end = 12.dp, + bottom = 8.dp, + ), verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.Icon(