ui: Real account button (avatar + display name), stable list keys

The top bar now mirrors SmartTube: a 44dp circle with the actual user
avatar (persisted at login alongside the display name; falls back to
the person icon) and the display name next to it.

Scrolling jank: add stable keys to grids and rows (item/channel ids)
- without keys Compose rebinds the whole list during scroll and
re-fires every AsyncImage + focus request, which is the realistic
culprit for the 2fps vertical scrolling on the TV.
This commit is contained in:
Jeena 2026-10-11 05:14:32 +00:00
parent cc1c1d1c9d
commit f7273121ae
7 changed files with 61 additions and 16 deletions

View file

@ -86,7 +86,14 @@ class PeerTubeRepository(
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
.build()
.create(PeerTubeApi::class.java)
store.accountName = meApi.me().username
val me = meApi.me()
store.accountName = me.username
store.userDisplayName = me.displayName ?: me.username
me.avatars.maxByOrNull { it.width }?.let { avatar ->
store.userAvatarUrl = avatar.fileUrl
?.takeIf { url -> url.startsWith("http") }
?: apiUrlOf(base) + avatar.path
}
} catch (_: Exception) {
store.accountName = username.substringBefore('@') // best effort fallback
}

View file

@ -137,5 +137,6 @@ data class VideoTokenFiles(
@Serializable
data class UserDto(
val username: String,
val displayName: String? = null,
val avatars: List<Avatar> = emptyList(),
)

View file

@ -48,6 +48,15 @@ class TokenStore(context: Context) {
get() = prefs.getString("refreshToken", null)
private set(value) = prefs.edit().putString("refreshToken", value).apply()
/** Display name + avatar for the account button (from users/me at login). */
var userDisplayName: String?
get() = prefs.getString("userDisplayName", null)
set(value) = prefs.edit().putString("userDisplayName", value).apply()
var userAvatarUrl: String?
get() = prefs.getString("userAvatarUrl", null)
set(value) = prefs.edit().putString("userAvatarUrl", value).apply()
/** Real account name from users/me — may differ from the login input (email). */
var accountName: String?
get() = prefs.getString("accountName", null)

View file

@ -262,7 +262,7 @@ private fun ChannelCardRow(
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(channels) { index, channel ->
itemsIndexed(channels, key = { _, c -> c.name + "@" + c.host }) { index, channel ->
val fr = remember { FocusRequester() }
focusRequesters[index] = fr
ChannelCard(

View file

@ -299,7 +299,7 @@ fun FocusScrollRow(
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(videos) { index, video ->
itemsIndexed(videos, key = { _, video -> "v" + video.id }) { index, video ->
val fr = remember { FocusRequester() }
focusRequesters[index] = fr
VideoCard(

View file

@ -239,31 +239,53 @@ private fun TopBar(
desc = "Search",
onClick = onSearchClick,
)
// "Who is logged in" — account button with the username (SmartTube shows the user).
// "Who is logged in" — account button: avatar circle + display name (SmartTube).
val accountFocused = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.clip(CircleShape)
.onFocusChanged { accountFocused.value = it.isFocused }
.background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent)
.background(Color.Transparent)
.then(
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
else Modifier
)
.focusable()
.clickable(onClick = onAccountClick)
.padding(6.dp),
.clickable(onClick = onAccountClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) {
val avatarUrl = AppGraph.tokenStore.userAvatarUrl
if (avatarUrl != null) {
coil.compose.AsyncImage(
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data(avatarUrl)
.size(96)
.build(),
contentDescription = "Account",
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0xFF262626)),
)
} else {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0xFF262626)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "Menu",
contentDescription = "Account",
tint = Color(0xFFCCCCCC),
)
}
}
Text(
text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
color = Color(0xFFCCCCCC),
text = AppGraph.tokenStore.userDisplayName
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
color = Color(0xFFB0B0B0),
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 8.dp),
)

View file

@ -124,7 +124,7 @@ fun VideoListScreen(
state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) {
itemsIndexed(items) { index, item ->
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video
is VideoListItem.Video -> item.video
@ -189,6 +189,12 @@ private fun SearchHeader(viewModel: VideoListViewModel) {
}
}
private fun itemStableKey(item: VideoListItem): String =
when (item) {
is VideoListItem.Video -> "v" + item.video.id
is VideoListItem.HistoryEntry -> "h" + item.history.video.id
}
private fun sourceKey(source: ListSource): String = when (source) {
is ListSource.Channel -> "channel:${source.handle}"
else -> source::class.java.simpleName