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.
This commit is contained in:
Jeena 2026-10-13 08:29:43 +00:00
parent 3d5fd44a59
commit 3d4de90671
2 changed files with 89 additions and 16 deletions

View file

@ -37,6 +37,8 @@ data class VideoChannelSummary(
@SerialName("displayName") val displayName: String, @SerialName("displayName") val displayName: String,
val host: String, val host: String,
val avatars: List<Avatar> = emptyList(), val avatars: List<Avatar> = emptyList(),
// Present on video detail responses — used for the subscriber count.
@SerialName("followersCount") val followersCount: Long? = null,
) )
@Serializable @Serializable
@ -156,7 +158,8 @@ data class InstanceInfoDto(
val shortDescription: String? = null, 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( data class ChannelStats(
val videos: Long, val videos: Long,
val plays: Long, val plays: Long,

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material.icons.filled.Pause 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.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.Replay10
@ -657,30 +659,60 @@ private fun PausedControls(
.focusable() .focusable()
.then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = detail.channel.avatars.maxByOrNull { it.width } val avatarUrl = detail.channel.avatars.maxByOrNull { it.width }?.let {
if (avatar != null) { it.fileUrl ?: AppGraph.repository.absoluteUrl(it.path)
}
if (avatarUrl != null) {
coil.compose.AsyncImage( coil.compose.AsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), model = avatarUrl,
contentDescription = "Open channel", contentDescription = "Open channel",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = channelModifier modifier = channelModifier
.clip(CircleShape), .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(
text = detail.name, text = detail.name,
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 2, maxLines = 2,
) )
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = detail.channel.displayName + " · " + formatViews(detail.views) + Text(
" · " + formatPublished(detail.publishedAt), text = detail.channel.displayName,
color = Color(0xFFCCCCCC), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp), )
) // 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. // The seek bar is the only playback control: focused = scrub, OK toggles.
@ -710,13 +742,14 @@ private fun PausedControls(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 10.dp), .padding(top = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
PlayerActionButton( PlayerActionButton(
icon = if (subscribed == true) Icons.Filled.NotificationsActive else Icons.Filled.NotificationsNone, icon = Icons.Filled.Groups,
desc = if (subscribed == true) "Subscribed" else "Subscribe", desc = if (subscribed == true) "Subscribed" else "Subscribe",
filled = subscribed == true, filled = subscribed == true,
flushLeft = true,
focusRequester = actionBarFocus, focusRequester = actionBarFocus,
) { done -> ) { done ->
scope.launch { scope.launch {
@ -789,6 +822,7 @@ private fun PausedControls(
formatDuration((durationMs / 1000).toInt()), formatDuration((durationMs / 1000).toInt()),
color = Color(0xFFCCCCCC), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium, 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. */ * subscribed/liked states get a solid accent background. */
@Composable @Composable
private fun PlayerActionButton( private fun PlayerActionButton(
@ -920,6 +982,9 @@ private fun PlayerActionButton(
desc: String, desc: String,
count: Long? = null, count: Long? = null,
filled: Boolean = false, 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, focusRequester: FocusRequester? = null,
// [onClick] receives an onDone callback; only call it when the action's // [onClick] receives an onDone callback; only call it when the action's
// network roundtrip finished — events arriving before onDone are dropped. // 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. // Click only for real pointer input; the key activation above is guarded.
.pointerInput(Unit) { detectTapGestures { activate() } } .pointerInput(Unit) { detectTapGestures { activate() } }
Row( 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
androidx.compose.material3.Icon( androidx.compose.material3.Icon(