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:
parent
3d5fd44a59
commit
3d4de90671
2 changed files with 89 additions and 16 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue