From dd7b7ebd21b13858be8449f71419097c5c4759ab Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:13:59 +0000 Subject: [PATCH 01/10] ui: All list screens use home-style 4-per-row card size Grid screens (My videos, History, Search, channel pages) switch from 3-column to adaptive columns sized exactly like the Home rows' cards, with the compact single-line title, so every screen reads the same. --- .../net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 1cfe009..0113c92 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 @@ -33,6 +33,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import net.jeena.jtubetv.R import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.screens.rowCardWidth import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.VideoListItem import net.jeena.jtubetv.ui.list.VideoListViewModel @@ -120,7 +121,8 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(3), + // Same card size as the Home rows: ~4 fit per row (user request). + columns = GridCells.Adaptive(rowCardWidth() - 12.dp), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { @@ -136,6 +138,7 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier .focusRequester(fr), From 5ebf06398d15d6ffb78872f99914fc8bee42b1ad Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:45:56 +0000 Subject: [PATCH 02/10] ui: Fix drawer focus-eater + BACK/RIGHT/LEFT menu affordances The drawer's tap-to-close scrim was focusable: LEFT/RIGHT from a drawer item moved focus onto the invisible fullscreen scrim, and OK mysteriously dismissed - the drawer felt un-navigable. Scrim is no longer focusable; RIGHT (from the drawer) closes it and BACK closes it; closing restores focus to the search button. LEFT on any row's leftmost card opens the drawer (used to be only the first row). Banner prefetch keys now match the render keys (480x270) so channel banners stop reloading on focus movement. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 334 ++++++++---------- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 21 +- 2 files changed, 173 insertions(+), 182 deletions(-) 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 707db29..9fa1e26 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 @@ -1,14 +1,9 @@ package net.jeena.jtubetv.ui.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -33,65 +35,61 @@ 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.input.key.onPreviewKeyEvent import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.foundation.BorderStroke import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto +import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -/** One row of channels belonging to one user (avatar + display name as header). */ -data class UserGroup( - val label: String?, - val avatar: Avatar?, - val videos: Long, - val rows: List, -) { - /** Sort weight: users with videos first (more videos first), empty channels last. */ - fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 -} - /** - * Channels as SmartTube-style rows: - * 1. "Your channels" — header with account avatar + username, then own channel cards. - * 2. "Local channels" — the instance's own channels. The public API has NO - * local-only filter (verified in v8.3.1: both /accounts and /video-channels - * listings ignore local filters), so we page the whole /video-channels list - * (max 100/page) and split by host client-side. - * 3. "Followed instances" — channels of instances this server follows. + * Channels, SmartTube-style: one row per LOCAL user. + * + * - Row 1: the logged-in user ("'s Channels"), then the other local + * users sorted by videos / subscribers / plays (desc). + * - Each row: that user's channels, same ordering; channels with no videos simply + * stay inside their user's row. + * - Followed-instance channels at the end, grouped per platform. + * + * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings + * have no isLocal filter and the listing carries no videosCount/views, so we page + * the full listing (max 100/page) and fetch per-channel stats (videos + sum of + * video views = plays) with small throttled calls. */ +data class UserGroup( + val ownerName: String, + val label: String, + val avatar: Avatar?, + val channels: List, +) + class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) private set - var localChannels by mutableStateOf>(emptyList()) - private set - /** One row per (local) user, sorted by total video count; empties last. */ - var localUserRows by mutableStateOf>(emptyList()) - private set - - /** Channels from instances this server follows, grouped by their instance. */ - var followedRows by mutableStateOf>(emptyList()) + var userRows by mutableStateOf>(emptyList()) private set var loading by mutableStateOf(true) @@ -99,8 +97,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> videos + plays; filled in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> stats; filled progressively in the background. */ + var stats by mutableStateOf>(emptyMap()) private set /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ @@ -110,7 +108,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private val repo: PeerTubeRepository = AppGraph.repository fun load(force: Boolean = false) { - if (!force && userChannels.isNotEmpty()) return + if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { @@ -120,11 +118,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() var start = 0L var total: Long? = null + var retries = 0 while (total == null || start < total) { - val page = repo.channelList(start, 100).getOrNull() ?: break + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue + } + retries = 0 if (total == null) total = page.second val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } @@ -132,54 +139,50 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val ownNames = userChannels.map { c -> c.name }.toSet() - val mine = all.filter { it.host == host && it.name in ownNames } - val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } - userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host } + val locals = all.filter { it.host == host } - net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) - - // platform display names (local cached in prefs; foreign lazy) - platformNames = mapOf( - (host ?: "") to (store.instanceName ?: host ?: ""), - ) - val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") - for (fh in foreignHosts) { - val name = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to name) + // platform display names: local one cached at login; foreign ones lazy + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) + for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { + val n = repo.instanceName(fh).getOrNull() ?: continue + platformNames = platformNames + (fh to n) } - // video + play counts per channel, one tiny listing call per channel - for (channel in (userChannels + localChannels)) { + // stats for every local channel: throttle below the server rate limit + val statsMap = HashMap() + for (channel in locals) { val key = channel.name + "@" + channel.host val cs = repo.channelStats(key).getOrNull() ?: continue - stats = stats + (key to cs) + statsMap[key] = cs + stats = statsMap.toMap() + delay(250) } - // group LOCAL channels by owner (user): row per user, videos desc, - // channels with zero videos collected into a single last row. - val locals = rest.filter { it.host == host } - val withVideos = locals.filter { it.videosCount > 0 } - val empty = locals.filter { it.videosCount <= 0 } - val byOwner = withVideos.groupBy { ownerKey(it) } - localUserRows = - byOwner.values - .map { row -> UserGroup( - label = rowLabel(row), - avatar = rowAvatar(row), - videos = rowTotal(row), - rows = row.sortedByDescending { it.videosCount }, - ) } - .filter { it.label != null } - .sortedByDescending { it.rowWeight() } - if (empty.isNotEmpty()) { - localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) + + // --- rows per user, sorted by videos / subscribers / plays ----------- + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sortedChannels = row.sortedWith( + compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + .thenByDescending { it.followersCount } + .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sortedChannels, + ) } - followedRows = rest.filter { it.host != host } - .groupBy { it.host ?: "?" } - .values - .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } - .sortedByDescending { it.rowWeight() } + // logged-in user first, then the others by videos / subs / plays + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + + ImagePrefetcher.channelArt(all + userChannels) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -188,11 +191,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" - private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName - private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } - private fun rowTotal(row: List) = row.sumOf { it.videosCount } - + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -212,11 +211,12 @@ fun ChannelsScreen( ) { LaunchedEffect(epoch) { viewModel.load(force = true) } + when { - viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + viewModel.loading && viewModel.userRows.isEmpty() -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { androidx.compose.material3.CircularProgressIndicator() } - viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + viewModel.error != null && viewModel.userRows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Could not load channels", color = MaterialTheme.colorScheme.error) @@ -231,60 +231,22 @@ fun ChannelsScreen( } } else -> { - val lastOpened = remember { mutableIntStateOf(-1) } - var focusedRow by remember { mutableIntStateOf(0) } - - androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { - if (viewModel.userChannels.isNotEmpty()) { - item { - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", - ) - } - item { - ChannelRow( - viewModel.userChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = true, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.localUserRows.forEach { group -> - item { + LazyColumn(Modifier.fillMaxSize()) { + viewModel.userRows.forEach { group -> + item(key = "hdr-" + group.ownerName) { RowHeader( avatar = group.avatar, - label = (group.label ?: "?") + " (" + group.videos + ")", + label = group.label, ) } - item { + item(key = "row-" + group.ownerName) { ChannelRow( - group.rows, + group.channels, onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.followedRows.forEach { group -> - item { - RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") - } - item { - ChannelRow( - group.rows, - onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, + onOpenDrawer, + firstFocused = group == viewModel.userRows.firstOrNull(), + stats = { viewModel.stats[it.name + "@" + it.host] }, + platformName = { viewModel.platformNames[it.host ?: ""] }, ) } } @@ -293,9 +255,9 @@ fun ChannelsScreen( } } -/** Row header: user icon + username, or plain label. */ +/** Row header: user icon + "'s Channels". */ @Composable -private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { +private fun RowHeader(avatar: Avatar?, label: String) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), @@ -303,7 +265,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = ) { if (avatar != null) { AsyncImage( - model = absolute(avatar.fileUrl ?: avatar.path), + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -316,24 +283,25 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = } } -private fun absolute(pathOrUrl: String): String = - if (pathOrUrl.startsWith("http")) pathOrUrl - else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl +private fun absoluteAvatar(avatar: Avatar): String = + (avatar.fileUrl ?: avatar.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } -/** Horizontal row of channel cards; eases newly focused cards into view. */ +/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ @Composable private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, - onOpenDrawer: () -> Unit, + onOpenDrawer: () -> Unit = {}, firstFocused: Boolean, - statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, - platformProvider: (VideoChannelDto) -> String?, + stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformName: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } - var focusedIndex by remember { mutableIntStateOf(-1) } - var clickedIndex by remember { mutableIntStateOf(-1) } + var focusedIndex by remember { mutableStateOf(-1) } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { @@ -351,7 +319,7 @@ private fun ChannelRow( } } - androidx.compose.foundation.lazy.LazyRow( + LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -362,8 +330,8 @@ private fun ChannelRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, - stats = statsProvider(channel), - platformName = platformProvider(channel), + stats = stats(channel), + platformName = platformName(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -371,16 +339,16 @@ private fun ChannelRow( } } -/** - * Channel card: banner top (center-cropped), round avatar bottom-left, then - * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. +/** + * Channel card: banner (center-cropped), round avatar bottom-left, channel name, + * "owner - platform" subtitle, then icon stats: videos | subscribers | plays. */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, - stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + stats: ChannelStats? = null, platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { @@ -424,8 +392,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(banner.fileUrl ?: banner.path)) - .size(480) + .data(absoluteBanner(banner)) + .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, @@ -438,8 +406,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(avatar.fileUrl ?: avatar.path)) - .size(80) + .data(absoluteAvatar(avatar)) + .size(80, 80) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -471,34 +439,42 @@ fun ChannelCard( maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, - value = stats?.videos?.toString() ?: "-", - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.Groups, - value = channel.followersCount.toString(), - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, - value = stats?.plays?.toString() ?: "-", - focused = cardFocused, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused) + StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused) + StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused) } } } } } +private fun absoluteBanner(banner: Avatar): String = + (banner.fileUrl ?: banner.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + @Composable -private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { +private fun StatWithIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + focused: Boolean, +) { val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { - androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + androidx.tv.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(13.dp), + ) Text(value, style = MaterialTheme.typography.labelSmall, color = color) } -} +} \ No newline at end of file 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 7e9927d..5cdefeb 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 @@ -81,6 +81,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } + val searchFocus = remember { FocusRequester() } // 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). @@ -115,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } lastBackAt = now drawerOpen = false + runCatching { searchFocus.requestFocus() } } Box( @@ -142,6 +144,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi TopBar( onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, + searchFocus = searchFocus, ) Box( modifier = Modifier @@ -236,6 +239,7 @@ private fun Destination.icon(): ImageVector = private fun TopBar( onSearchClick: () -> Unit, onAccountClick: () -> Unit, + searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( @@ -249,6 +253,7 @@ private fun TopBar( 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) } @@ -317,6 +322,7 @@ private fun RoundTopButton( icon: ImageVector, desc: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Box( @@ -330,6 +336,7 @@ private fun RoundTopButton( 3.dp, Color.White, CircleShape ) else Modifier ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() .clickable(onClick = onClick), contentAlignment = Alignment.Center, @@ -364,7 +371,16 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp), + .padding(vertical = 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 && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT + ) { + onDismiss() + true + } else false + }, ) { Text( text = "JTube TV", @@ -401,12 +417,11 @@ private fun MenuDrawer( modifier = Modifier, ) } - // tap/click outside closes; focus LEFT from the drawer also closes + // tap/click outside closes the drawer Box( modifier = Modifier .weight(1f) .fillMaxSize() - .focusable() .pointerInput(Unit) { detectTapGestures { onDismiss() } }, ) } From be221e44b19d5cf42805988bb7df34feb198db67 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:01:27 +0000 Subject: [PATCH 03/10] channels: Render rows early (pages only), guard rebuild, Long-safe sort keys The channels screen waited for every listing page AND all per-channel stats before showing anything - a 40s+ blank spinner. Rows now render per page (unsorted), re-sorting as stat batches arrive. rebuildRows is guarded; the earlier ClassCastException (Integer into a Long comparator) surfaced via the sort call and can no longer abort the app. All sort keys go through Long-typed helpers. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) 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 9fa1e26..5decf11 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 @@ -36,6 +36,7 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale @@ -105,18 +106,30 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var platformNames by mutableStateOf>(emptyMap()) private set + private var allChannels: List = emptyList() + + /** Channels from followed instances, grouped per platform. */ + var followedRows by mutableStateOf>(emptyList()) + private set + private val repo: PeerTubeRepository = AppGraph.repository + private val statsMap = HashMap() + + fun load(force: Boolean = false) { if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { + // The user's own row renders FIRST (fast: one call), the rest streams in. user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() @@ -136,53 +149,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + allChannels = all + rebuildRows(host) } - - val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val locals = all.filter { it.host == host } - - // platform display names: local one cached at login; foreign ones lazy - platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { - val n = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to n) - } - - // stats for every local channel: throttle below the server rate limit - val statsMap = HashMap() - for (channel in locals) { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() ?: continue - statsMap[key] = cs - stats = statsMap.toMap() - delay(250) - } - - // --- rows per user, sorted by videos / subscribers / plays ----------- - val ownNames = userChannels.map { c -> c.name }.toSet() - val byOwner = locals.groupBy { ownerKey(it) } - val groups = byOwner.values.map { row -> - val sortedChannels = row.sortedWith( - compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } - .thenByDescending { it.followersCount } - .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, - ) - val own = row.firstOrNull { it.name in ownNames } - UserGroup( - ownerName = ownerKey(row.first()), - label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", - avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, - channels = sortedChannels, - ) - } - // logged-in user first, then the others by videos / subs / plays - userRows = groups.sortedWith( - compareByDescending { g -> g.channels.any { it.name in ownNames } } - .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } - .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, - ) - ImagePrefetcher.channelArt(all + userChannels) + + // stats (videos + plays) for every local channel: throttled parallel-ish + val locals = all.filter { it.host == host } + channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -191,6 +165,73 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Fetch stats for [channels], re-sorting rows as data lands. */ + private fun channelStatsLoop(channels: List, host: String?) { + viewModelScope.launch { + for (channel in channels) { + val key = channel.name + "@" + channel.host + if (statsMap.containsKey(key)) continue + val cs = repo.channelStats(key).getOrNull() ?: continue + statsMap[key] = cs + stats = statsMap.toMap() + rebuildRows(host) + delay(120) + } + } + } + + private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L + private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L + + /** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */ + private fun rebuildRows(host: String?) { + runCatching { rebuildRowsInner(host) }.onFailure { + android.util.Log.e("JTube", "rebuildRows failed", it) + } + } + + private fun rebuildRowsInner(host: String?) { + val locals = allChannels.filter { it.host == host } + val foreign = allChannels.filter { it.host != host } + followedRows = foreign + .groupBy { it.host ?: "?" } + .values + .map { row -> + UserGroup( + ownerName = row.firstOrNull()?.host ?: "?", + label = (platformNames[row.firstOrNull()?.host] + ?: row.firstOrNull()?.host ?: "?") + " (followed)", + avatar = null, + channels = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount }, + ), + ) + } + .sortedByDescending { g -> g.channels.sumOf { it.followersCount } } + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sorted = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sorted, + ) + } + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { @@ -358,6 +399,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && From c11638f3757e817fa2f8d0681ef7e2efa2f8199e Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:28:19 +0000 Subject: [PATCH 04/10] ui: BACK = up the hierarchy (channel->channels->home->exit guard) The BACK-opens-drawer behavior made navigation loops with the drawer focus restore; replaced with the TV-standard: BACK leaves the player, closes the drawer, leaves a channel drilldown, or goes Home; from Home it is the double-press exit guard. Drawer opens via the account button / LEFT on a leftmost card. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 20 +++++++++- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 39 ++++++++----------- 2 files changed, 36 insertions(+), 23 deletions(-) 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 5decf11..0ee6ab5 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 @@ -49,6 +49,7 @@ import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import android.util.Log import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -149,6 +150,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + android.util.Log.w( + "JTube", + "channels page " + (start / 100) + ": total=" + total + " all=" + all.size + + " locals=" + all.count { it.host == host }, + ) allChannels = all rebuildRows(host) } @@ -156,6 +162,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { // stats (videos + plays) for every local channel: throttled parallel-ish val locals = all.filter { it.host == host } + Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + + " userChannels=" + userChannels.size) channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" @@ -171,7 +179,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { for (channel in channels) { val key = channel.name + "@" + channel.host if (statsMap.containsKey(key)) continue - val cs = repo.channelStats(key).getOrNull() ?: continue + val cs = repo.channelStats(key).getOrNull() + if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } + android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) statsMap[key] = cs stats = statsMap.toMap() rebuildRows(host) @@ -230,6 +240,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) + Log.w( + "JTube", + "order now: " + userRows.take(6).joinToString(" | ") { g -> + g.ownerName + "(" + g.channels.size + "ch, " + + g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + "v" + + (if (g.channels.any { it.name in ownNames }) ", OWN" else "") + ")" + }, + ) } private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" 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 5cdefeb..a9d790b 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 @@ -76,7 +76,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle - var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + // NOT saveable: launching the app should never resume a half-watched player session. + var playingVideo by remember { 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 @@ -89,35 +90,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer close → channel drilldown → - // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. - BackHandler(enabled = playingVideo == null && channelDetails == null) { - val now = System.currentTimeMillis() - if (drawerOpen) { + // 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() } + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { + channelDetails = null + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) { + if (destination != Destination.Home) { + channelDetails = null + destination = Destination.Home + } else { + val now = System.currentTimeMillis() if (now - lastBackAt < 2500) { (context as? android.app.Activity)?.finish() return@BackHandler } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() - } else { - lastBackAt = now - drawerOpen = true } } - BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { - channelDetails = null - } - BackHandler(enabled = drawerOpen && playingVideo == null) { - val now = System.currentTimeMillis() - if (now - lastBackAt < 2500) { - (context as? android.app.Activity)?.finish() - return@BackHandler - } - lastBackAt = now - drawerOpen = false - runCatching { searchFocus.requestFocus() } - } Box( modifier = Modifier From f9b0dd0dbcc31baa10e712f681b5dfd8e0bf912b Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:31:18 +0000 Subject: [PATCH 05/10] ui: Explicit UP from first content row to the top bar Focus search silently failed to move from the first content card up to the top bar (why the menu felt unreachable); the first row's cards now have a wired UP target (the search button) in rows and grids. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 ++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 5 +++++ .../jtubetv/ui/screens/SubscriptionsScreen.kt | 2 ++ .../jtubetv/ui/screens/list/VideoCard.kt | 19 +++++++++++++------ .../ui/screens/list/VideoListScreen.kt | 2 ++ 5 files changed, 26 insertions(+), 6 deletions(-) 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 69dafd1..97aa411 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 @@ -153,6 +153,7 @@ fun HomeRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } @@ -188,6 +189,7 @@ fun HomeRowsScreen( onLeftAtStart = onOpenDrawer, clickedIndex = continueRestore, restoreToken = restoreToken, + upToTopBar = upToTopBar, ) } } @@ -257,6 +259,7 @@ fun FocusScrollRow( onLeftAtStart: () -> Unit, clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click restoreToken: Int = 0, // bumped when playback ends + upToTopBar: (() -> Unit)? = null, // first content row only afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -315,6 +318,7 @@ fun FocusScrollRow( compact = true, progress = progressOf(video.shortUUID), onLeftAtStart = if (index == 0) onLeftAtStart else null, + onUpToTopBar = upToTopBar, onFocused = { focusedIndex = index }, ) afterItem(index, video) 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 a9d790b..5a66d6c 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 @@ -154,6 +154,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -162,6 +163,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -175,6 +177,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> SubscriptionRowsScreen( @@ -182,6 +185,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -190,6 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt index 3a73e01..d6e7eff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -82,6 +82,7 @@ fun SubscriptionRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: SubscriptionRowsViewModel = viewModel( key = "subsrows$epoch", factory = object : ViewModelProvider.Factory { @@ -157,6 +158,7 @@ fun SubscriptionRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() }, ) { rowIndex, _ -> if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { viewModel.loadMore() diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 4adffc4..3d4a240 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -59,6 +59,8 @@ fun VideoCard( compact: Boolean = false, /** LEFT (only on the row's first card) can open the menu drawer. */ onLeftAtStart: (() -> Unit)? = null, + /** Explicit UP-target for the first content row (focus search misses the top bar). */ + onUpToTopBar: (() -> Unit)? = null, /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ onFocused: (() -> Unit)? = null, ) { @@ -72,12 +74,17 @@ fun VideoCard( if (it.isFocused) onFocused?.invoke() } .onPreviewKeyEvent { ke -> - if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && - ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && - onLeftAtStart != null - ) { - onLeftAtStart() - true + val direction = ke.key + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) { + when { + direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> { + onLeftAtStart(); true + } + direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> { + onUpToTopBar(); true + } + else -> false + } } else false }, colors = CardDefaults.colors( 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 0113c92..02e43b3 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 @@ -45,6 +45,7 @@ fun VideoListScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -140,6 +141,7 @@ fun VideoListScreen( showPrivacy = source is ListSource.MyVideos, compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, + onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), ) From 7894847144f0c937c7d0960f2b385cc435f5a228 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:44:35 +0000 Subject: [PATCH 06/10] ui: CENTER activates everything (clickable lacks DPAD_CENTER) Compose clickable handles Enter/Space only; whether the TV's OK (DPAD_CENTER) triggers click depends on the device's input stack - worked on the API-36 host emulator, dead on the API-34 ATD and possibly some TVs. A preview-tunnelled center handler now activates drawer items, top-bar buttons, video and channel cards, and the login/search buttons deterministically, exactly once (preview consumption prevents the platform path from double-firing). --- .../jtubetv/ui/screens/ChannelsScreen.kt | 2 ++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 4 ++++ .../jeena/jtubetv/ui/screens/SetupScreen.kt | 19 +++++++++++---- .../jtubetv/ui/screens/list/FocusKeys.kt | 24 +++++++++++++++++++ .../jtubetv/ui/screens/list/VideoCard.kt | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt 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 0ee6ab5..8bcf936 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 @@ -62,6 +62,7 @@ import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Channels, SmartTube-style: one row per LOCAL user. @@ -417,6 +418,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && 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 5a66d6c..b031715 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 @@ -55,6 +55,7 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable enum class Destination(val title: String) { Home("Home"), @@ -266,6 +267,7 @@ private fun TopBar( else Modifier ) .focusable() + .dpadCenterActivatable(onAccountClick) .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), @@ -338,6 +340,7 @@ private fun RoundTopButton( ) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -454,6 +457,7 @@ private fun MenuDrawerItem( } ) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt index 3f4fc88..a41f989 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Feature 0: ask for instance URL + credentials once. The password is used only for @@ -164,9 +165,6 @@ fun SetupScreen( Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error) } Button( - // Deliberately always focusable: on TV a permanently disabled button - // is unreachable and leaves the user stuck without feedback (UX review). - // Validation happens on press with an inline message instead. onClick = { when { url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> @@ -178,7 +176,20 @@ fun SetupScreen( } }, enabled = !busy.value, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .dpadCenterActivatable( + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + ), ) { if (busy.value) { CircularProgressIndicator(Modifier.width(20.dp)) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt new file mode 100644 index 0000000..b7adec0 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -0,0 +1,24 @@ +package net.jeena.jtubetv.ui.screens.list + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.onPreviewKeyEvent + +/** + * Compose `clickable` handles Enter/Space but NOT the TV's DPAD_CENTER; whether + * CENTER works depends on each device's input translation (works on some emulators, + * not on others — see the API-34 ATD case). This modifier consumes CENTER *first* + * (preview/tunneling) so the click fires exactly once, on every device. + */ +fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { + onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER + ) { + onClick() + true + } else false + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 3d4a240..5d4e1a9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -69,6 +69,7 @@ fun VideoCard( onClick = onClick, modifier = modifier .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused if (it.isFocused) onFocused?.invoke() From 1a8d8fce5b69c84bd393978dd1cad2f8d15be711 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:47:06 +0000 Subject: [PATCH 07/10] =?UTF-8?q?scripts:=20emulator-seed.sh=20=E2=80=94?= =?UTF-8?q?=20seed=20a=20fresh=20emulated=20device=20with=20tvbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/emulator-seed.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 scripts/emulator-seed.sh diff --git a/scripts/emulator-seed.sh b/scripts/emulator-seed.sh new file mode 100755 index 0000000..7a4bd91 --- /dev/null +++ b/scripts/emulator-seed.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# emulator-seed.sh — (container-side) log the freshly booted emulated device into the +# JTube test account via the debug-only seed intent. Usage: +# ./emulator-seed.sh emulator-5568 [serial] +set -euo pipefail +SERIAL="${1:-emulator-5568}" +ADB="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb" +[ -x "$ADB" ] && ADB="$ADB" || ADB="$(which adb)" + +# credentials from the encrypted store +eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + +# kill + cold start so onCreate (the debug seeder) actually runs +"$ADB" -s "$SERIAL" shell am force-stop net.jeena.jtubetv 2>/dev/null || true +"$ADB" -s "$SERIAL" install -r "$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" 2>&1 | tail -1 + +"$ADB" -s "$SERIAL" shell am start -a android.intent.action.MAIN \ + -n net.jeena.jtubetv/.MainActivity \ + --es jtube_url "$JTUBE_URL" \ + --es jtube_user "$JTUBE_USER" \ + --es jtube_pass "$JTUBE_PASS" + +sleep 12 +"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 +"$ADB" -s "$SERIAL" shell cat /sdcard/ui.xml | python3 -c " +import sys, re +xml = sys.stdin.read() +texts = [m.group(1) for m in re.finditer(r'text=\"([^\"]{2,40})\"', xml)] +foc = re.search(r']*focused=\"true\"[^>]*>', xml) +print('logged in as:', texts[0] if texts else '?') +print('rows/screens:', texts[1:8])" \ No newline at end of file From 12462e6f90da7449a7be78de3aca0f4d47e2e490 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 08/10] channels: Show a banner and harden D-pad activation Channel pages now load the channel record itself and use the newest video's thumbnail as a banner fallback, so channels never render without a header image. Focus cards in re-sorting rows only hand out initial focus once per screen visit so streaming stats can't steal focus mid-navigation. D-pad activation now also accepts ENTER/NUMPAD_ENTER/SPACE, matching how the Compose platform fires clickable cards; consuming in the preview phase keeps OK from double-activating. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 13 ++++++++-- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 2 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 5 ++++ .../jtubetv/ui/list/VideoListViewModel.kt | 20 ++++++++++++++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 24 ++++++++++++++----- .../jtubetv/ui/screens/list/FocusKeys.kt | 15 ++++++++---- 6 files changed, 66 insertions(+), 13 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 5079c0d..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,14 +125,20 @@ class PeerTubeRepository( var videos = 0L var plays = 0L var total: Long? = null + var latestThumb: String? = null repeat(maxPages) { val page = network.api.channelVideos(handle, start, 25) - if (total == null) total = page.total + if (total == null) { + total = page.total + latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path -> + if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/') + } + } plays += page.data.sumOf { it.views } start += page.data.size videos = total ?: videos } - net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb) } private fun unauthenticatedApiFor(base: String): PeerTubeApi = @@ -178,6 +184,9 @@ class PeerTubeRepository( response.data to response.total } + suspend fun videoChannel(handle: String): Result = + runCatching { network.api.videoChannel(handle) } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) 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 b807ebc..91d3777 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 @@ -158,6 +158,8 @@ data class InstanceInfoDto( data class ChannelStats( val videos: Long, val plays: Long, + /** Thumbnail of the newest video — banner fallback for channels without one. */ + val latestThumbUrl: String? = null, ) @Serializable diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 4c505ce..1c5940a 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -83,6 +83,11 @@ interface PeerTubeApi { @Path("account") account: String, ): VideoChannelListResponse + @GET("video-channels/{handle}") + suspend fun videoChannel( + @Path("handle") handle: String, + ): VideoChannelDto + @GET("video-channels/{handle}/videos") suspend fun channelVideos( @Path("handle") handle: String, 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 33bf4aa..a723e8e 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 @@ -1,8 +1,13 @@ package net.jeena.jtubetv.ui.list +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository @@ -30,6 +35,12 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + /** For channel pages: the channel itself (banner + display name) as page header. */ + var channelHeader by androidx.compose.runtime.mutableStateOf( + null, + ) + private set + override fun onChunkLoaded(chunk: List) { ImagePrefetcher.videoThumbnails( chunk.mapNotNull { @@ -60,6 +71,15 @@ class VideoListViewModel( } } + fun loadChannelHeader(repository: PeerTubeRepository) { + val s = source + if (s is ListSource.Channel) { + viewModelScope.launch { + channelHeader = repository.videoChannel(s.handle).getOrNull() + } + } + } + fun triggerSearch(raw: String) { query.value = raw activeSearch = raw.trim().ifBlank { 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 8bcf936..a97a119 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 @@ -291,6 +291,10 @@ fun ChannelsScreen( } } else -> { + // hand the initial focus to the first row exactly ONCE per screen visit: + // as stats stream in the rows re-sort, and re-focusing their first card + // would steal focus mid-navigation (user: "up jumps to the first row"). + val focusHandedOut = remember { mutableStateOf(false) } LazyColumn(Modifier.fillMaxSize()) { viewModel.userRows.forEach { group -> item(key = "hdr-" + group.ownerName) { @@ -304,7 +308,9 @@ fun ChannelsScreen( group.channels, onOpenChannel, onOpenDrawer, - firstFocused = group == viewModel.userRows.firstOrNull(), + focused = !focusHandedOut.value && + group == viewModel.userRows.firstOrNull(), + onFocused = { focusHandedOut.value = true }, stats = { viewModel.stats[it.name + "@" + it.host] }, platformName = { viewModel.platformNames[it.host ?: ""] }, ) @@ -355,7 +361,8 @@ private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit = {}, - firstFocused: Boolean, + focused: Boolean, + onFocused: () -> Unit = {}, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformName: (VideoChannelDto) -> String?, ) { @@ -373,9 +380,11 @@ private fun ChannelRow( if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } - if (firstFocused) { + if (focused) { LaunchedEffect(channels.isNotEmpty()) { - if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + if (channels.isNotEmpty()) { + if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() + } } } @@ -448,13 +457,16 @@ fun ChannelCard( .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { + // banner, or the newest video's thumbnail for bannerless channels val banner = channel.banners.maxByOrNull { it.width } - if (banner != null) { + val coverUrl = banner?.let { absoluteBanner(it) } + ?: stats?.latestThumbUrl + if (coverUrl != null) { AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absoluteBanner(banner)) + .data(coverUrl) .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt index b7adec0..7088eb4 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -14,11 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent */ fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { onPreviewKeyEvent { ke -> - if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && - ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER - ) { - onClick() - true + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + val code = ke.nativeKeyEvent.keyCode + val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + code == android.view.KeyEvent.KEYCODE_ENTER || + code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER || + code == android.view.KeyEvent.KEYCODE_SPACE + if (activates) { + onClick() + true // consumed in preview -> the platform/card path never double-fires + } else false } else false } } \ No newline at end of file From ee5748b68d9f578b59ab33ae36c18c51f07cc2aa Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 09/10] ui: Use the JTube TV logo for the launcher icon and TV banner Replace the placeholder red play-square with the real logo (the orange TV from https://jeena.net/jtube-logo.svg) converted to vector drawables. The old placeholder still lives on in git history; the launcher now matches the website branding. --- app/src/main/res/drawable/app_banner.xml | 51 ++++++++++++++----- app/src/main/res/drawable/icon_foreground.xml | 39 ++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml index 29ba394..5d78e70 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -7,16 +7,43 @@ - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_foreground.xml b/app/src/main/res/drawable/icon_foreground.xml index 74dc88e..28febb9 100644 --- a/app/src/main/res/drawable/icon_foreground.xml +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -5,13 +5,42 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.6" + android:scaleY="0.6" + android:translateX="27.5" + android:translateY="26.5"> + android:fillColor="#F2690D" + android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" /> + + android:pathData="M15.0279 42.3829 c 14.4103 -2.9379 34.4644 -4.7509 48.0973 -4.21 5.1043 0.2025 9.3683 4.8586 9.1618 9.7687 -0.2835 6.7398 -1.0404 18.8371 -1.477 25.8761 -0.3086 4.9748 -4.3214 9.3362 -8.8365 9.4034 -14.5318 0.2161 -28.9546 -0.025 -43.8451 0.6671 -4.5318 0.2106 -8.7268 -3.2379 -9.2264 -7.5956 C 7.993 68.3645 6.7849 60.7535 5.9079 52.9211 c -0.4974 -4.4414 3.7431 -9.442 9.1201 -10.5382 z" /> + + + + + + + + \ No newline at end of file From 73b1c86790f761a19a67a26d4b98fbb3f363cbeb Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:27:08 +0000 Subject: [PATCH 10/10] docs: Drop instance-specific storage-security notes The public repo must not document this instance's object-storage access-control posture; findings like that belong in private ops notes. The engineering guidance (never rely on URL obscurity, always request file tokens) stays. --- .gitignore | 4 +++- AGENTS.md | 7 ++++--- BACKLOG.md | 14 ++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index f89a0ab..8685299 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ /app/build /captures .externalNativeBuild -.cxxreports/*-shots/ +.cxx +reports/*-shots/ +reports/*.md diff --git a/AGENTS.md b/AGENTS.md index 1e68716..bc283be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a whose media lives in a private object-storage bucket — pass the short-lived `POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs (response shape: `{ files: { token, expires } }`; verified live). - - tube.jeena.net itself streams non-public media from a **publicly readable R2 - bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on - that — always request the file token for non-public videos. + - Some instances stream non-public media from object-storage hosts with only + URL-based access control — never rely on that; always request and use the + file token for non-public videos. (Instance-specific findings stay out of + the public repo and live in the private ops notes/agent-local instructions.) ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 08cad82..5ac444d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - Logout / switch account: sign-out entry exists; add switch-account (re-login screen without losing scroll state). - Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. -- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: - instance thumbnail paths are publicly readable (HTTP 200 without auth); add a - fallback poster only if other instances behave differently. -- Security note for tube.jeena.net: object-storage (R2) media for private test - videos is publicly readable without token ("private" only via URL obscurity). - Flag to the instance admin; the app must not rely on this laxness. +- Thumbnails of private/internal videos may 404 — on some instances thumbnail + paths are publicly readable while on others they need auth; add a fallback + poster only if needed. +- Security note (verified live against tube.jeena.net): some instances serve + non-public media / thumbnails from object storage with only URL-based access + control. The app must not rely on that laxness; always send auth headers + / file tokens for non-public media. (Detail intentionally omitted from the + public repo — report instance-specific findings privately.) - Verify `searchTarget=local` channel search behaves well for instances without federation; consider `search/video-channels` endpoint as an alternative. - Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.