From f7273121aed5b60b05d769e86c669d184e8fde6c Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:14:32 +0000 Subject: [PATCH] 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. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 9 +++- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 1 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 9 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 2 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 46 ++++++++++++++----- .../ui/screens/list/VideoListScreen.kt | 8 +++- 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 93a0e9e..64d35bb 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -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 } 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 231aa31..41cf173 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 @@ -137,5 +137,6 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val displayName: String? = null, val avatars: List = emptyList(), ) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt index 2620641..0b006ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -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) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index b7a065d..00ca382 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 5867b5a..625a6d0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index a602d88..b2de95a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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), ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = "Menu", - tint = Color(0xFFCCCCCC), - ) + 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 = "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), ) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 8e3570e..1cfe009 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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