diff --git a/BACKLOG.md b/BACKLOG.md index f9e6182..4b29b20 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,6 +13,14 @@ Status vocabulary: `todo` · `doing` · `done`. | 4 | Subscription videos | done | Verified incl. remote (federated) channels | | 5 | Watch history (server) | done | read-only grid | | 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | +| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon | + +## Discussed but deferred (2026-10-12, while reworking the player controls) + +- Like/dislike display: counts are in the video detail DTO; rating (own like via + `PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later. +- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration` + on the player; language selection in the controls overlay. ## Next up (v0.2+) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index a723e8e..dca398c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -74,9 +74,7 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { - viewModelScope.launch { - channelHeader = repository.videoChannel(s.handle).getOrNull() - } + viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() } } } 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 1cd6eea..74cf99a 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 @@ -259,7 +259,7 @@ fun HomeRowsScreen( videos = viewModel.subItems, progressOf = { null }, onOpenVideo = onOpenVideo, - onLeftAtStart = { }, + onLeftAtStart = onOpenDrawer, ) } } 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 a6f5aad..cd1c618 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 @@ -1,8 +1,8 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -12,10 +12,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.AccountCircle @@ -36,14 +39,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput @@ -51,6 +56,8 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.Text import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.R +import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -67,23 +74,28 @@ enum class Destination(val title: String) { } /** - * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, - * full-width rows/list content. The menu drawer slides in when wanted (avatar or - * the search/BACK flows) and everything is key(epoch) so an account switch - * rebuilds all state. + * Home = SmartTube-style: persistent top bar with the app logo + title on the + * left and the account button (avatar + display name, opens the menu) on the + * right; the menu drawer slides in below the bar so the logo/title stay in the + * same place whether the drawer is open or not. Content below is key(epoch) so + * an account switch rebuilds all state. */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() + val userProfile by viewModel.userProfile.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle + var channelName by rememberSaveable { mutableStateOf(null) } // display name // NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } - val searchFocus = remember { FocusRequester() } + + /** Close the drawer; focus falls back into the content pane. */ + val dismissDrawer = { drawerOpen = false } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -100,8 +112,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi // BACK order (TV standard): player (own handler) → drawer close → channel // drilldown → non-Home destination → Home; from Home: double-press exits. BackHandler(enabled = drawerOpen && playingVideo == null) { - drawerOpen = false - runCatching { searchFocus.requestFocus() } + dismissDrawer() } BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { channelDetails = null @@ -133,35 +144,48 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi playingVideo == null ) { when { - drawerOpen -> { drawerOpen = false; true } + drawerOpen -> { dismissDrawer(); true } channelDetails != null -> { channelDetails = null; true } else -> false } } else false }, ) { - androidx.compose.runtime.key(epoch) { Column { - // SmartTube-style top bar: red round search + avatar opening the menu. + // Persistent top bar: logo/title left, account button right. TopBar( - onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, - searchFocus = searchFocus, + profile = userProfile, ) - Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 8.dp), - ) { - when (destination) { + androidx.compose.runtime.key(epoch) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), + ) { + when { + // Channel videos page (opened from the Channels screen or the + // player's channel icon) overrides the destination content. + channelDetails != null -> + VideoListScreen( + source = ListSource.Channel(channelDetails!!, channelName ?: ""), + onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, + epoch = epoch, + onOpenDrawer = { drawerOpen = true }, + upToTopBar = { drawerOpen = true }, + ) + else -> when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + // UP over the first row opens the drawer. + upToTopBar = { drawerOpen = true }, ) Destination.MyVideos -> VideoListScreen( @@ -170,7 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -184,7 +208,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Subscriptions -> VideoListScreen( @@ -193,7 +217,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.History -> VideoListScreen( @@ -202,31 +226,38 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) + } + } + } + + // Slide-in menu drawer, below the top bar so logo/title stay put. + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { dismissDrawer() }, + ) + } } } } - } - - // Slide-in menu drawer (SmartTube style: hidden until wanted). - if (drawerOpen) { - MenuDrawer( - selected = destination, - onSelect = { - destination = it - channelDetails = null - drawerOpen = false - }, - onSignOut = { viewModel.logout() }, - onDismiss = { drawerOpen = false }, - ) - } if (playingVideo != null) { PlayerScreen( shortUuid = playingVideo!!, onBack = { playingVideo = null }, + onOpenChannel = { handle, displayName -> + playingVideo = null + channelDetails = handle + channelName = displayName + }, ) } } @@ -242,120 +273,109 @@ private fun Destination.icon(): ImageVector = Destination.History -> Icons.Filled.History } -/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +/** + * Persistent top bar: logo + "JTube TV" on the left (unchanged while the drawer + * is open, so they anchor the layout) and the account button on the right. + */ @Composable private fun TopBar( - onSearchClick: () -> Unit, onAccountClick: () -> Unit, - searchFocus: FocusRequester, + profile: PeerTubeRepository.UserProfile?, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 10.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - RoundTopButton( - bg = Color(0xFFCE2B2B), - icon = Icons.Filled.Search, - desc = "Search", - onClick = onSearchClick, - focusRequester = searchFocus, - ) - // "Who is logged in" — account button: avatar circle + display name (SmartTube). - val accountFocused = remember { mutableStateOf(false) } - Row( + // The launcher icon is an adaptive icon, which Compose can't paint + // directly — the launcher look is its two layers: dark background + + // foreground glyph (both vector drawables). + // Bottom-aligned to the text line and nudged up so the logo's bottom + // edge sits on the text baseline, not below it. + Box( modifier = Modifier - .onFocusChanged { accountFocused.value = it.isFocused } - .background(Color.Transparent) - .then( - if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) - else Modifier - ) - .focusable() - .dpadCenterActivatable(onAccountClick) - .clickable(onClick = onAccountClick), + .align(Alignment.Bottom) + .padding(bottom = 17.dp) // slightly below the text baseline + .size(32.dp) // match the avatar row so both headers align + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF101010)), + ) { + // Zoom past the adaptive-icon safe zone: the glyph is only ~45% of + // the 108dp viewport, so Fit would render it tiny. + Image( + painter = painterResource(R.drawable.icon_foreground), + contentDescription = "JTube TV logo", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .scale(2.1f), + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + // Account indicator: avatar + display name. Purely visual on TV (D-pad + // focus and activation stay in the content/drawer); a click (mouse/touch) + // still opens the menu drawer. + Row( + modifier = Modifier.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 = "Account", - tint = Color(0xFFCCCCCC), - ) - } - } + AccountAvatar( + url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl, + size = 40.dp, + ) Text( - text = AppGraph.tokenStore.userDisplayName - ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", - color = Color(0xFFB0B0B0), + text = profile?.displayName + ?: AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') + ?: "Account", + color = Color(0xFFD0D0D0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 8.dp), + modifier = Modifier + .padding(end = 8.dp) + // keep the button compact; the bar never wraps under a long name + .widthIn(max = 160.dp), + maxLines = 1, ) } - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "JTube TV", - color = Color(0xFF9A9A9A), - style = androidx.compose.material3.MaterialTheme.typography.titleMedium, - modifier = Modifier.align(Alignment.CenterVertically), - ) } } - @Composable -private fun RoundTopButton( - bg: Color, - icon: ImageVector, - desc: String, - onClick: () -> Unit, - focusRequester: FocusRequester? = null, -) { - var focused by remember { mutableStateOf(false) } - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .onFocusChanged { focused = it.isFocused } - .background(bg) - .then( - if (focused) Modifier.border( - 3.dp, Color.White, CircleShape - ) else Modifier - ) - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .focusable() - .dpadCenterActivatable(onClick) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = desc, - tint = Color.White, +private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) { + if (url != null) { + coil.compose.AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(url) + .size(96) + .build(), + contentDescription = "Account", + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(Color(0xFF262626)), ) + } else { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(Color(0xFF262626)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Account", + tint = Color(0xFFCCCCCC), + ) + } } } @@ -381,7 +401,9 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp) + // Logo/title live in the persistent top bar above; only a slim + // top gap here so the item list starts below the bar. + .padding(top = 8.dp, bottom = 24.dp) .onPreviewKeyEvent { ke -> // RIGHT from the drawer closes it; focus returns to the top bar. if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && @@ -392,12 +414,6 @@ private fun MenuDrawer( } else false }, ) { - Text( - text = "JTube TV", - color = Color.White, - style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), - ) Destination.entries.forEachIndexed { index, dest -> MenuDrawerItem( icon = dest.icon(), 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 02e43b3..17d7681 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 @@ -2,9 +2,12 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed @@ -15,21 +18,24 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.R import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel @@ -80,9 +86,19 @@ fun VideoListScreen( key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - when (source) { - is ListSource.Search -> SearchHeader(viewModel) - else -> {} + if (source is ListSource.Channel) { + // Fetch the banner/avatar/counts for the channel page header. + LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } + } + + // Cards-per-row of the adaptive grid — LEFT from the first card of ANY row + // must open the drawer (issue #7: "every time I'm all the way on the left"). + val cellsPerRow by remember { + androidx.compose.runtime.derivedStateOf { + val info = gridState.layoutInfo + val w = info.visibleItemsInfo.firstOrNull()?.size?.width ?: 0 + if (w > 0) (info.viewportSize.width / w).coerceIn(1, 10) else 0 + } } if (viewModel.loadingInitial && items.isEmpty()) { @@ -127,6 +143,15 @@ fun VideoListScreen( state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { + // Page headers (search field / channel banner) live INSIDE the grid so + // they scroll with the content and never stack under the cards. + item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + when (source) { + is ListSource.Search -> SearchHeader(viewModel) + is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader) + else -> {} + } + } itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video @@ -140,7 +165,7 @@ fun VideoListScreen( onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, compact = true, - onLeftAtStart = if (index == 0) onOpenDrawer else null, + onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null, onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), @@ -194,6 +219,54 @@ private fun SearchHeader(viewModel: VideoListViewModel) { } } +/** Channel page header: banner strip, then avatar + display name + handle + counts. */ +@Composable +private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?) { + if (channelHeader == null) return + androidx.compose.foundation.layout.Column { + val banner = channelHeader.banners.maxByOrNull { it.width } + if (banner != null) { + AsyncImage( + model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val avatar = channelHeader.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = 16.dp, top = 10.dp) + .size(52.dp) + .clip(androidx.compose.foundation.shape.CircleShape), + ) + } + Column(modifier = Modifier.padding(start = 16.dp)) { + Text( + text = channelHeader.displayName, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + ) + Text( + text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") + + channelHeader.name + "@" + channelHeader.host, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + private fun itemStableKey(item: VideoListItem): String = when (item) { is VideoListItem.Video -> "v" + item.video.id