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:
parent
cc1c1d1c9d
commit
f7273121ae
7 changed files with 61 additions and 16 deletions
|
|
@ -86,7 +86,14 @@ class PeerTubeRepository(
|
||||||
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
|
||||||
.build()
|
.build()
|
||||||
.create(PeerTubeApi::class.java)
|
.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) {
|
} catch (_: Exception) {
|
||||||
store.accountName = username.substringBefore('@') // best effort fallback
|
store.accountName = username.substringBefore('@') // best effort fallback
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,5 +137,6 @@ data class VideoTokenFiles(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UserDto(
|
data class UserDto(
|
||||||
val username: String,
|
val username: String,
|
||||||
|
val displayName: String? = null,
|
||||||
val avatars: List<Avatar> = emptyList(),
|
val avatars: List<Avatar> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
@ -48,6 +48,15 @@ class TokenStore(context: Context) {
|
||||||
get() = prefs.getString("refreshToken", null)
|
get() = prefs.getString("refreshToken", null)
|
||||||
private set(value) = prefs.edit().putString("refreshToken", value).apply()
|
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). */
|
/** Real account name from users/me — may differ from the login input (email). */
|
||||||
var accountName: String?
|
var accountName: String?
|
||||||
get() = prefs.getString("accountName", null)
|
get() = prefs.getString("accountName", null)
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ private fun ChannelCardRow(
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(channels) { index, channel ->
|
itemsIndexed(channels, key = { _, c -> c.name + "@" + c.host }) { index, channel ->
|
||||||
val fr = remember { FocusRequester() }
|
val fr = remember { FocusRequester() }
|
||||||
focusRequesters[index] = fr
|
focusRequesters[index] = fr
|
||||||
ChannelCard(
|
ChannelCard(
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,7 @@ fun FocusScrollRow(
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(videos) { index, video ->
|
itemsIndexed(videos, key = { _, video -> "v" + video.id }) { index, video ->
|
||||||
val fr = remember { FocusRequester() }
|
val fr = remember { FocusRequester() }
|
||||||
focusRequesters[index] = fr
|
focusRequesters[index] = fr
|
||||||
VideoCard(
|
VideoCard(
|
||||||
|
|
|
||||||
|
|
@ -239,31 +239,53 @@ private fun TopBar(
|
||||||
desc = "Search",
|
desc = "Search",
|
||||||
onClick = onSearchClick,
|
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) }
|
val accountFocused = remember { mutableStateOf(false) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(CircleShape)
|
|
||||||
.onFocusChanged { accountFocused.value = it.isFocused }
|
.onFocusChanged { accountFocused.value = it.isFocused }
|
||||||
.background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent)
|
.background(Color.Transparent)
|
||||||
.then(
|
.then(
|
||||||
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
|
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
|
||||||
else Modifier
|
else Modifier
|
||||||
)
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(onClick = onAccountClick)
|
.clickable(onClick = onAccountClick),
|
||||||
.padding(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
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(
|
Icon(
|
||||||
imageVector = Icons.Filled.AccountCircle,
|
imageVector = Icons.Filled.AccountCircle,
|
||||||
contentDescription = "Menu",
|
contentDescription = "Account",
|
||||||
tint = Color(0xFFCCCCCC),
|
tint = Color(0xFFCCCCCC),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
text = AppGraph.tokenStore.userDisplayName
|
||||||
color = Color(0xFFCCCCCC),
|
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
|
||||||
|
color = Color(0xFFB0B0B0),
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ fun VideoListScreen(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items) { index, item ->
|
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
||||||
val video: VideoDto = when (item) {
|
val video: VideoDto = when (item) {
|
||||||
is VideoListItem.HistoryEntry -> item.history.video
|
is VideoListItem.HistoryEntry -> item.history.video
|
||||||
is VideoListItem.Video -> item.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) {
|
private fun sourceKey(source: ListSource): String = when (source) {
|
||||||
is ListSource.Channel -> "channel:${source.handle}"
|
is ListSource.Channel -> "channel:${source.handle}"
|
||||||
else -> source::class.java.simpleName
|
else -> source::class.java.simpleName
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue