From f5b37dd21daa4fd6931195d69ea4558203e96468 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 14:25:21 +0000 Subject: [PATCH 1/2] player: wire the D-pad through the action row, panels and playlist deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action-row buttons lost their modifier in the icon-only rework and became unfocusable, so D-pad DOWN off the paused seek bar landed nowhere (requestFocus failed with "not initialized"). The modifier is attached to the button again and the seek bar ↔ buttons directions are wired explicitly, because the default focus search skips the bar. Pill visuals use the new state language: no border, gray pill = focused, red glyph = subscribed/rated; the sidebar panels keep the D-pad inside their lists (consumed at the edges). BACK/Esc acts on ACTION_DOWN only — its trailing UP saw the panel already closed and exited the video — and panels return focus to the pill that opened them. Playlist deletion is a requested scope addition: holding OK on a row asks via a dialog (DELETE /video-playlists/{id}); the toggle moves to key release so the hold cannot double-act. Holding also exposed an activation bug: the CENTER helper consumed only ACTION_DOWN and the release leaked into the underlying clickable — now consumed, repeats swallowed, so Create (and the cards) fire exactly once per press. An info panel shows the video description with clickable links: inline (pointer) plus a focusable link row list (D-pad); PeerTube video and channel links play/drill in-app, other links open the browser. Closes #12 --- BACKLOG.md | 7 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 29 +- .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 3 + .../jeena/jtubetv/ui/player/PlayerSidebar.kt | 363 ++++++++++++++++-- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 1 + .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 139 ++++--- .../jtubetv/ui/screens/list/FocusKeys.kt | 68 +++- 7 files changed, 498 insertions(+), 112 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 4b29b20..1ee7edd 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -77,4 +77,9 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - Comments viewing (read-only) — needs moderation-safe UI thinking. - `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items. - Out of scope by user request (do not implement without asking): uploading, editing, - subscribing/unsubscribing, deleting, creating channels/users/playlists. \ No newline at end of file + creating channels/users. (Stale members of this list — subscribing, playlists + viewing/creation and rating — shipped with the player action row in v0.1; playlist + deletion was requested and added later, see below.) +- Delete own playlist: long-press OK on a playlist row (key-repeat) → confirmation + dialog → `DELETE /api/v1/video-playlists/{id}`; toggle stays on short press. Requested + 2026-10-13. \ No newline at end of file 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 3138989..2f81d77 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,32 +125,6 @@ class PeerTubeRepository( api.serverConfig().instance.name ?: rawHost } - /** - * videosCount + total plays for a channel (listing DTOs don't carry them). - * Sums views over up to [maxPages] video pages. - */ - suspend fun channelStats(handle: String, maxPages: Int = 4): Result = - runCatching { - var start = 0L - 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 - 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, latestThumb) - } - private fun unauthenticatedApiFor(base: String): PeerTubeApi = Retrofit.Builder() .baseUrl(apiUrlOf(base) + "/") @@ -297,6 +271,9 @@ class PeerTubeRepository( suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result = runCatching { network.api.removeFromPlaylist(playlistId, elementId) } + suspend fun deletePlaylist(playlistId: Long): Result = + runCatching { network.api.deletePlaylist(playlistId) } + suspend fun createPlaylist(displayName: String): Result = runCatching { val created = network.api.createPlaylist(CreatePlaylistBody(displayName)) .videoPlaylist ?: throw IllegalStateException("No playlist in response") 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 2ef6672..5301b22 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 @@ -150,6 +150,9 @@ interface PeerTubeApi { @Body body: AddToPlaylistBody, ) + @DELETE("video-playlists/{playlistId}") + suspend fun deletePlaylist(@Path("playlistId") playlistId: Long): Response + @DELETE("video-playlists/{playlistId}/videos/{elementId}") suspend fun removeFromPlaylist( @Path("playlistId") playlistId: Long, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt index 43bc01c..ced63ff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -35,6 +35,13 @@ 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.text.style.TextDecoration +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle + +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.type @@ -43,10 +50,15 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.foundation.border import coil.compose.AsyncImage import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable +import net.jeena.jtubetv.ui.screens.list.dpadCenterTwoStage import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto import net.jeena.jtubetv.data.api.CommentThreadDto import net.jeena.jtubetv.data.api.VideoDetailDto @@ -66,9 +78,13 @@ private val Accent = Color(0xFFCE2B2B) @Composable fun PlayerSidebar( - mode: String, // "comments" | "playlists" + mode: String, // "comments" | "playlists" | "info" detail: VideoDetailDto, onDismiss: () -> Unit, + // PeerTube video links from the info panel's description. + onOpenVideo: (String) -> Unit = {}, + // PeerTube channel links (drill down to the channel's page). + onOpenChannel: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column( @@ -78,15 +94,148 @@ fun PlayerSidebar( .background(SidebarBg), ) { Text( - text = if (mode == "playlists") "Playlists" else "Comments", + text = when (mode) { + "playlists" -> "Playlists" + "info" -> "Info" + else -> "Comments" + }, color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(20.dp), ) - if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss) + when (mode) { + "playlists" -> PlaylistsColumn(detail, onDismiss) + "info" -> InfoColumn(detail, onDismiss, onOpenVideo, onOpenChannel) + else -> CommentsColumn(detail, onDismiss) + } } } +// ---- info (issue #12) ------------------------------------------------------ + +private val UrlPattern = Regex("https?://[^\\s<>\"()]+") + +@Composable +private fun InfoColumn( + detail: VideoDetailDto, + onDismiss: () -> Unit, + onOpenVideo: (String) -> Unit, + onOpenChannel: (String) -> Unit, +) { + val context = androidx.compose.ui.platform.LocalContext.current + val description = detail.description ?: "" + + // PeerTube video/channel URLs stay in the app; everything else goes to the browser. + fun open(url: String) { + val trimmed = url.trimEnd('.', ',', ')') + val uri = runCatching { android.net.Uri.parse(trimmed) }.getOrNull() + if (uri != null) { + val path = uri.path ?: "" + val last = path.split('/').filter { it.isNotBlank() }.lastOrNull() ?: "" + if (path.startsWith("/w/") || path.startsWith("/videos/watch/")) { + if (last.isNotBlank()) { + onOpenVideo(last) + onDismiss() + return + } + } + if (path.startsWith("/video-channels/") || path.startsWith("/c/")) { + if (last.isNotBlank()) { + onOpenChannel(last) + onDismiss() + return + } + } + } + runCatching { + context.startActivity( + android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(trimmed)) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + + // distinct links in first-appearance order + val links = UrlPattern.findAll(description) + .map { it.value.trimEnd('.', ',', ')') } + .distinct() + .toList() + + val firstLinkFocus = remember { FocusRequester() } + LaunchedEffect(links) { + if (links.isNotEmpty()) { + kotlinx.coroutines.delay(150) + runCatching { firstLinkFocus.requestFocus() } + } + } + + LazyColumn(Modifier.fillMaxSize()) { + if (description.isNotBlank()) { + item(key = "desc") { + Text( + text = buildAnnotatedString { + var rest = description + while (true) { + val match = UrlPattern.find(rest) ?: break + append(rest.substringBefore(match.value)) + val url = match.value.trimEnd('.', ',', ')') + withLink( + LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle( + color = Color(0xFF66B2FF), + textDecoration = TextDecoration.Underline, + ), + ), + { _ -> open(url) }, + ), + ) { append(url) } + rest = rest.substringAfter(match.value) + } + append(rest) + }, + color = TextMain, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } + } + if (links.isNotEmpty()) { + item(key = "linkshdr") { + Text( + text = "Links", + color = TextDim, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp), + ) + } + links.forEachIndexed { index, url -> + item(key = "l" + index + url) { + SidebarRow( + onLeft = onDismiss, + focusRequester = if (index == 0) firstLinkFocus else null, + onClick = { open(url) }, + ) { + Text( + text = linkLabel(url), + color = TextMain, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + } + } + } + } +} + +/** Link click listener: the annotation callback shape. */ +private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann) + +/** Short label for a link row: host + path (the URL itself, folded). */ +private fun linkLabel(url: String): String = url + /** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */ @Composable private fun SidebarFallback(message: String, onDismiss: () -> Unit) { @@ -102,11 +251,17 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) { .fillMaxWidth() .padding(20.dp) .onPreviewKeyEvent { ke -> - if (ke.type == KeyEventType.KeyDown && - ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT - ) { - onDismiss() - true + if (ke.type == KeyEventType.KeyDown) { + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + onDismiss(); true + } + // A one-row panel: no navigation possible — stay put + // instead of letting focus escape to the action row. + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_RIGHT -> true + else -> false + } } else false } .focusRequester(fr) @@ -114,13 +269,23 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) { ) } -/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */ +/** Sidebar row: focusable with visual focus; LEFT closes the sidebar; DOWN/UP + * are consumed at the list's first/last row so focus never escapes the panel + * — unless an [onUp] hook moves focus deliberately (e.g. to the text field + * above the playlists list) — the exit-cancel focusProperties clashed with + * the rows' own close-and-move, so edges are handled per row. */ @Composable private fun SidebarRow( modifier: Modifier = Modifier, onLeft: (() -> Unit)? = null, + onUp: (() -> Unit)? = null, + atTop: Boolean = false, + atBottom: Boolean = false, focusRequester: FocusRequester? = null, onClick: (() -> Unit)? = null, + // Long press on OK opens a context action (e.g. playlist removal) instead + // of the click — the click itself fires on key release then. + onLongPress: (() -> Unit)? = null, content: @Composable () -> Unit, ) { var focused by remember { mutableStateOf(false) } @@ -129,19 +294,31 @@ private fun SidebarRow( .onFocusChanged { focused = it.isFocused } .background(if (focused) Color(0x1AFFFFFF) else Color.Transparent) if (focusRequester != null) m = m.focusRequester(focusRequester) - if (onLeft != null) { - m = m.onPreviewKeyEvent { ke -> - if (ke.type == KeyEventType.KeyDown && - ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT - ) { - onLeft() - true - } else false + m = m.onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (onLeft != null) onLeft() + onLeft != null + } + // No focusable beyond the list's ends in this direction: + // swallowing the key keeps focus inside the panel. + KeyEvent.KEYCODE_DPAD_DOWN -> atBottom + KeyEvent.KEYCODE_DPAD_UP -> { + if (onUp != null) { + onUp() + true + } else atTop + } + else -> false } } - m = m - .dpadCenterActivatable(onClick ?: {}) - .focusable() + m = if (onLongPress != null) { + m.dpadCenterTwoStage(onClick ?: {}, onLongPress) + } else { + m.dpadCenterActivatable(onClick ?: {}) + } + m = m.focusable() if (onClick != null) m = m.clickable(onClick = onClick) Row( modifier = m.padding(horizontal = 20.dp, vertical = 10.dp), @@ -212,6 +389,8 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { CommentRow( comment = comment, onLeft = onDismiss, + atTop = index == 0, + atBottom = index == items.size - 1, focusRequester = if (index == 0) firstRow else null, ) } @@ -235,12 +414,16 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { private fun CommentRow( comment: CommentThreadDto, onLeft: () -> Unit, + atTop: Boolean = false, + atBottom: Boolean = false, focusRequester: FocusRequester? = null, ) { val avatar = comment.account?.avatars?.maxByOrNull { it.width } SidebarRow( modifier = Modifier.padding(vertical = 4.dp), onLeft = onLeft, + atTop = atTop, + atBottom = atBottom, focusRequester = focusRequester, ) { if (avatar != null) { @@ -283,19 +466,34 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { val scope = rememberCoroutineScope() var playlists by remember { mutableStateOf>(emptyList()) } var containedBy by remember { mutableStateOf>(emptyMap()) } + // Optimistic flips: shown immediately when toggled, then folded away once + // the server's membership check has settled (a roundtrip can take a + // second — pressing without instant feedback feels broken). + var addedOptimistically by remember { mutableStateOf>(emptySet()) } + var removedOptimistically by remember { mutableStateOf>(emptySet()) } var loading by remember { mutableStateOf(true) } var failed by remember { mutableStateOf(false) } var newName by remember { mutableStateOf("") } val inputFocus = remember { FocusRequester() } val rowsFocus = remember { FocusRequester() } + val createBtnFocus = remember { FocusRequester() } + var busy by remember { mutableStateOf(false) } suspend fun refreshHits() { repository.playlistsContaining(detail.id).fold( - onSuccess = { containedBy = it }, + onSuccess = { + containedBy = it + addedOptimistically = emptySet() + removedOptimistically = emptySet() + }, onFailure = { }, ) } + fun checked(playlistId: Long): Boolean = + (containedBy.containsKey(playlistId) && playlistId !in removedOptimistically) || + playlistId in addedOptimistically + LaunchedEffect(detail.id) { repository.myPlaylists().fold( onSuccess = { playlists = it }, @@ -306,16 +504,28 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { } fun toggle(playlist: VideoPlaylistSummaryDto) { - val elementId = containedBy[playlist.id] + // One toggle at a time: a double-activated press (some devices fire + // CENTER twice — see PlayerActionButton) or spam clicks could race + // add/remove against stale element ids. + if (busy) return + val inside = checked(playlist.id) + busy = true scope.launch { - if (elementId != null) { - repository.removeFromPlaylist(playlist.id, elementId) + if (inside) { + val elementId = containedBy[playlist.id] ?: return@launch + if (repository.removeFromPlaylist(playlist.id, elementId).isSuccess) { + removedOptimistically = removedOptimistically + playlist.id + } } else { - repository.addToPlaylist(playlist.id, detail.id) + addedOptimistically = addedOptimistically + playlist.id + if (repository.addToPlaylist(playlist.id, detail.id).isFailure) { + addedOptimistically = addedOptimistically - playlist.id + } } // Membership checks settle server-side; re-check after a moment. kotlinx.coroutines.delay(1000) refreshHits() + busy = false } } @@ -332,6 +542,27 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { } } + // Long-press on a row asks; confirm deletes via DELETE video-playlists/{id}. + var removeCandidate by remember { mutableStateOf(null) } + fun requestRemove(playlist: VideoPlaylistSummaryDto) { + if (!busy) removeCandidate = playlist + } + fun delete(playlist: VideoPlaylistSummaryDto) { + busy = true + scope.launch { + if (repository.deletePlaylist(playlist.id).isSuccess) { + playlists = playlists.filter { it.id != playlist.id } + containedBy = containedBy - playlist.id + // The removed row leaves focus dangling — park it on the rows. + kotlinx.coroutines.delay(150) + runCatching { rowsFocus.requestFocus() } + kotlinx.coroutines.delay(1000) + refreshHits() + } + busy = false + } + } + LaunchedEffect(loading) { if (!loading && !failed && playlists.isNotEmpty()) { kotlinx.coroutines.delay(150) @@ -354,11 +585,35 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { modifier = Modifier .weight(1f) .focusRequester(inputFocus) + // The field is the D-pad entry point to creating a playlist: + // UP from the first list row lands here (opens the IME) and + // DOWN returns to the list. + .onPreviewKeyEvent { ke -> + if (ke.type == KeyEventType.KeyDown) { + when (ke.nativeKeyEvent.keyCode) { + // DOWN returns to the list; RIGHT goes to the + // Create button (single-line TV field — cursor + // moves are worth less than being able to leave). + KeyEvent.KEYCODE_DPAD_DOWN -> { + runCatching { rowsFocus.requestFocus() } + true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + runCatching { createBtnFocus.requestFocus() } + true + } + else -> false + } + } else false + } .padding(start = 16.dp), ) Button( onClick = { create() }, - modifier = Modifier.padding(start = 10.dp, end = 16.dp), + modifier = Modifier + .padding(start = 10.dp, end = 16.dp) + .focusRequester(createBtnFocus) + .dpadCenterActivatable({ create() }), ) { Text("Create") } } when { @@ -368,11 +623,17 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { else -> LazyColumn(Modifier.fillMaxSize()) { playlists.forEachIndexed { index, playlist -> - val checked = containedBy.containsKey(playlist.id) + val checked = checked(playlist.id) item(key = "p" + playlist.id) { SidebarRow( onLeft = onDismiss, + onUp = if (index == 0) { + { runCatching { inputFocus.requestFocus() } } + } else null, + atTop = index == 0, + atBottom = index == playlists.size - 1, onClick = { toggle(playlist) }, + onLongPress = { requestRemove(playlist) }, focusRequester = if (index == 0) rowsFocus else null, ) { PlaylistCheckbox(checked) @@ -389,6 +650,33 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { } } } + // Long-press confirmation: the playlist's videos stay on the instance. + removeCandidate?.let { candidate -> + val cancelFocus = remember { FocusRequester() } + LaunchedEffect(candidate) { + kotlinx.coroutines.delay(150) + runCatching { cancelFocus.requestFocus() } + } + androidx.compose.material3.AlertDialog( + onDismissRequest = { removeCandidate = null }, + title = { Text("Remove playlist?") }, + text = { + Text("\"" + candidate.displayName + "\" will be deleted. The videos in it stay on the instance.") + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + removeCandidate = null + delete(candidate) + }) { Text("Remove") } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { removeCandidate = null }, + modifier = Modifier.focusRequester(cancelFocus), + ) { Text("Cancel") } + }, + ) + } } @Composable @@ -397,8 +685,25 @@ private fun PlaylistCheckbox(checked: Boolean) { modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(5.dp)) - .background(if (checked) Accent else Color(0x33FFFFFF)), - ) + .background(Color(0x33FFFFFF)) + .border( + 1.dp, + if (checked) Accent else Color(0x66FFFFFF), + RoundedCornerShape(5.dp), + ), + contentAlignment = Alignment.Center, + ) { + // A red check inside the box marks membership (a plain red box read as + // a status color, not as "checked"). + if (checked) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(16.dp), + ) + } + } } private fun formatDateShort(iso: String?): String { 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 b2770aa..a13eb09 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 @@ -257,6 +257,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi channelDetails = handle channelName = displayName }, + onOpenVideo = { playingVideo = it }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index dc9e192..b77e291 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsNone import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material.icons.filled.Replay10 @@ -194,6 +195,8 @@ fun PlayerScreen( // From the paused/controls overlay's channel icon: (handle, displayName), // must also exit the player screen first. onOpenChannel: (String, String) -> Unit = { _, _ -> }, + // From the info sidebar's PeerTube video links: switches the player over. + onOpenVideo: (String) -> Unit = {}, viewModel: PlayerViewModel = viewModel( key = "player$shortUuid", factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), @@ -211,7 +214,7 @@ fun PlayerScreen( when (val s = state) { is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) - is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo) } } @@ -274,6 +277,7 @@ private fun PlayerSurface( shortUuid: String, onBack: () -> Unit, onOpenChannel: (String, String) -> Unit, + onOpenVideo: (String) -> Unit, ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } @@ -292,6 +296,14 @@ private fun PlayerSurface( val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } // Right sidebar (issue #10): "comments" | "playlists"; null = closed. var sidebar by remember { mutableStateOf(null) } + // The pill that opened the sidebar — on close it regains focus, not the + // first pill of the row. + var sidebarReturn by remember { mutableStateOf(null) } + fun closeSidebar() { + sidebar = null + runCatching { (sidebarReturn ?: actionBarFocus).requestFocus() } + sidebarReturn = null + } val player = remember { val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) @@ -465,12 +477,15 @@ private fun PlayerSurface( val code = ke.nativeKeyEvent.keyCode when { // BACK/Esc: close the sidebar first, else leave the player. + // Only on ACTION_DOWN: the trailing ACTION_UP of the same + // press must not see sidebar==null and exit the video. code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { - if (sidebar != null) { - sidebar = null - runCatching { actionBarFocus.requestFocus() } - } else { - onBack() + if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { + if (sidebar != null) { + closeSidebar() + } else { + onBack() + } } true } @@ -551,21 +566,22 @@ private fun PlayerSurface( seekBarFocus = seekBarFocus, actionBarFocus = actionBarFocus, onOpenChannel = onOpenChannel, - openSidebar = { mode -> + openSidebar = { mode, opener -> sidebar = mode + sidebarReturn = opener }, modifier = Modifier.align(Alignment.BottomCenter), ) } - // Right sidebar (comments / playlists); the video is shrunk to its left. + // Right sidebar (comments / playlists / info); the video is shrunk to its left. sidebar?.let { mode -> PlayerSidebar( mode = mode, detail = s.detail, onDismiss = { - sidebar = null - runCatching { actionBarFocus.requestFocus() } + closeSidebar() }, + onOpenVideo = onOpenVideo, modifier = Modifier .align(Alignment.CenterEnd) .padding(top = 48.dp), @@ -575,6 +591,8 @@ private fun PlayerSurface( } /** Seek ±[deltaMs] inside the video bounds; updates the overlay's position state. */ +private val VideoAccent = Color(0xFFCE2B2B) + private fun seekOverlay( player: ExoPlayer, positionMs: androidx.compose.runtime.MutableLongState, @@ -605,13 +623,18 @@ private fun PausedControls( onOpenChannel: (String, String) -> Unit = { _, _ -> }, // First action-row button focus (used by the surface when a sidebar closes). actionBarFocus: FocusRequester? = null, - // "comments" | "playlists" — opens the right sidebar (issue #10). - openSidebar: (String) -> Unit = {}, + // "comments" | "playlists" — opens the right sidebar (issue #10); the + // opener pill's focuser travels along so it regains focus on close. + openSidebar: (String, FocusRequester) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { val durationMs = detail.duration * 1000L val scope = androidx.compose.runtime.rememberCoroutineScope() val channelFocus = remember { FocusRequester() } + // Focusers of the sidebar-opening pills (their panels come back to them). + val commentsPillFocus = remember { FocusRequester() } + val playlistsPillFocus = remember { FocusRequester() } + val infoPillFocus = remember { FocusRequester() } // While playing, the position text must keep counting. LaunchedEffect(playing) { while (playing) { @@ -726,7 +749,7 @@ private fun PausedControls( modifier = Modifier.padding(top = 12.dp), ) // Action row under the red line (issue #10): subscribe, like/dislike, - // comments + playlists open the right sidebar; times on the right. + // info + comments/playlists open the right sidebar; times on the right. val repository = AppGraph.repository val scope = androidx.compose.runtime.rememberCoroutineScope() var subscribed by remember { mutableStateOf(null) } @@ -743,13 +766,15 @@ private fun PausedControls( modifier = Modifier .fillMaxWidth() .padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerActionButton( + upFocus = seekBarFocus, icon = Icons.Filled.Groups, desc = if (subscribed == true) "Subscribed" else "Subscribe", filled = subscribed == true, - flushLeft = true, + iconSize = androidx.compose.ui.unit.DpSize(33.dp, 33.dp), focusRequester = actionBarFocus, ) { done -> scope.launch { @@ -760,17 +785,18 @@ private fun PausedControls( repository.subscribe(channelHandle).isSuccess } if (ok) { - // Optimistic: the server's exist-check is eventually - // consistent right after a POST — trust our action, then - // re-verify a moment later. + // Trust the user's action: the server's exist-check can + // lag minutes for federated channels (the follow must be + // accepted remotely), so a quick re-verify would stomp + // a perfectly good state back to unsubscribed. The + // state re-syncs from the server on next screen entry. subscribed = !wasSubscribed - kotlinx.coroutines.delay(1500) - repository.subscriptionExists(channelHandle).onSuccess { subscribed = it } } done() } } PlayerActionButton( + upFocus = seekBarFocus, icon = Icons.Filled.ThumbUp, desc = "Like", count = likeCount, @@ -787,6 +813,7 @@ private fun PausedControls( } } PlayerActionButton( + upFocus = seekBarFocus, icon = Icons.Filled.ThumbDown, desc = "Dislike", count = dislikeCount, @@ -803,18 +830,31 @@ private fun PausedControls( } } PlayerActionButton( - icon = Icons.Filled.ChatBubbleOutline, - desc = "Comments", + upFocus = seekBarFocus, + icon = Icons.Filled.Info, + desc = "Info", + focusRequester = infoPillFocus, ) { done -> done() - openSidebar("comments") + openSidebar("info", infoPillFocus) } PlayerActionButton( - icon = Icons.Filled.PlaylistAdd, - desc = "Playlists", + upFocus = seekBarFocus, + icon = Icons.Filled.ChatBubbleOutline, + desc = "Comments", + focusRequester = commentsPillFocus, ) { done -> done() - openSidebar("playlists") + openSidebar("comments", commentsPillFocus) + } + PlayerActionButton( + upFocus = seekBarFocus, + icon = Icons.Filled.PlaylistAdd, + desc = "Playlists", + focusRequester = playlistsPillFocus, + ) { done -> + done() + openSidebar("playlists", playlistsPillFocus) } Spacer(modifier = Modifier.weight(1f)) Text( @@ -982,10 +1022,12 @@ private fun PlayerActionButton( desc: String, count: Long? = null, filled: Boolean = false, - // First pill in the row: drop the left inner padding so the glyph aligns - // flush with the 24dp text margin. - flushLeft: Boolean = false, + // Optically size the glyph: outlines/filled people glyphs differ in + // internal padding — subscribe's Groups icon reads smaller at equal dp. + iconSize: androidx.compose.ui.unit.DpSize = androidx.compose.ui.unit.DpSize(26.dp, 26.dp), focusRequester: FocusRequester? = null, + // UP target: the seek bar. Default focus search skips the bar and lands on the channel icon above it, so it is wired explicitly. + upFocus: FocusRequester? = null, // [onClick] receives an onDone callback; only call it when the action's // network roundtrip finished — events arriving before onDone are dropped. onClick: (onDone: () -> Unit) -> Unit, @@ -1006,36 +1048,43 @@ private fun PlayerActionButton( if (focusRequester != null) m = m.focusRequester(focusRequester) m = m .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { ke -> + // UP returns to the seek bar explicitly — default focus search skips + // it and lands on the channel icon further up (observed live). + if (ke.type == KeyEventType.KeyDown && + ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_UP + ) { + runCatching { upFocus?.requestFocus() } + upFocus != null + } else false + } .dpadCenterActivatable({ activate() }) .focusable() + // Selection: gray pill; nothing at all otherwise — the active state + // (subscribed / rated) is marked by the icon turning red instead. .background( - when { - focused -> Color(0x55FFFFFF) - filled -> Color(0x33CE2B2B) - else -> Color(0x22FFFFFF) - }, + if (focused) Color(0x55FFFFFF) else Color.Transparent, RoundedCornerShape(20.dp), ) - .then( - if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp)) - else Modifier - ) // Click only for real pointer input; the key activation above is guarded. .pointerInput(Unit) { detectTapGestures { activate() } } + // Icon inset inside the pill — must come AFTER the background so the + // gray pill wraps the glyph on every side. + .padding(horizontal = 16.dp, vertical = 9.dp) + // [m] carries focusRequester/onFocusChanged/dpadCenterActivatable/focusable + // plus background, border and the icon inset — it must stay attached to + // the Row, otherwise the button is not focusable at all (D-pad can't reach + // it) and the visuals collapse. Row( - modifier = Modifier.padding( - start = if (flushLeft) 0.dp else 12.dp, - top = 8.dp, - end = 12.dp, - bottom = 8.dp, - ), + modifier = m, verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.Icon( imageVector = icon, contentDescription = desc, - tint = Color.White, - modifier = Modifier.size(26.dp), + // Same red as the seek bar/playlist accent — marks active state. + tint = if (filled) VideoAccent else Color.White, + modifier = Modifier.size(iconSize), ) if (count != null) { Text( 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 7088eb4..c1fc078 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 @@ -1,7 +1,11 @@ package net.jeena.jtubetv.ui.screens.list +import android.view.KeyEvent import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -14,16 +18,58 @@ 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) { - 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 + val native = ke.nativeKeyEvent + val code = native.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 + // Fires exactly once per press, on ACTION_DOWN: the UP is consumed too + // (otherwise it leaks into an underlying clickable, which fires again — + // observed live: the Create button created a playlist twice), and key + // repeats are swallowed so holding OK cannot machine-gun the action. + when (native.action) { + android.view.KeyEvent.ACTION_DOWN -> { + if (activates) { + if (native.repeatCount == 0) onClick() + true + } else false + } + android.view.KeyEvent.ACTION_UP -> activates + else -> false + } + } +} + +/** + * Two-stage TV activation for hold-sensitive rows: a short press runs + * [onClick]; holding OK for ~600 ms (checked at key release, not during — + * so the dialog the long-press opens never receives the trailing repeats + * and release of the hold that triggered it) runs [onLongPress] instead + * and suppresses the click. Everything is consumed; the platform click + * that some devices fire on top of CENTER never double-activates. + */ +fun Modifier.dpadCenterTwoStage(onClick: () -> Unit, onLongPress: () -> Unit): Modifier = composed { + var downAt by remember { mutableStateOf(0L) } + onPreviewKeyEvent { ke -> + val native = ke.nativeKeyEvent + val code = native.keyCode + val activates = code == KeyEvent.KEYCODE_DPAD_CENTER || + code == KeyEvent.KEYCODE_ENTER || + code == KeyEvent.KEYCODE_NUMPAD_ENTER || + code == KeyEvent.KEYCODE_SPACE + if (!activates) return@onPreviewKeyEvent false + when (native.action) { + KeyEvent.ACTION_DOWN -> { + if (native.repeatCount == 0) downAt = android.os.SystemClock.uptimeMillis() + true + } + KeyEvent.ACTION_UP -> { + val held = downAt > 0 && android.os.SystemClock.uptimeMillis() - downAt >= 600 + if (held) onLongPress() else onClick() + true + } + else -> false + } } } \ No newline at end of file From 29dee24849198175db8b76adfcbfe7585c97e367 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 14:25:31 +0000 Subject: [PATCH 2/2] channels: build the local rows from the video listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering Channels walked thirteen 100-row pages of the all-channels listing (1237 rows on this instance, most of them federated follows) and re-sorted the rows after every page; the signed-in user's own row only appeared at page 12, which read as "loading one channel after another" and never kept icons visible above the fold. The local video listing already carries what the cards show: paging its few pages yields exact per-channel video/play counts, covers, channel avatars and the owner accounts for the row headers — no per-channel requests. The all-channels listing now runs behind it as a background metadata refinement (banners, exact subscriber counts, zero-video local channels, followed platforms) and rebuilds rows only when a group actually changes, so the visible rows don't churn. Re-entering the screen within five minutes repaints the kept rows untouched. Closes #13 --- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 3 + .../jtubetv/ui/screens/ChannelsScreen.kt | 176 ++++++++++++------ 2 files changed, 121 insertions(+), 58 deletions(-) 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 44961b0..6ca85b5 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 @@ -50,6 +50,9 @@ data class VideoDto( val duration: Int = 0, @SerialName("publishedAt") val publishedAt: String? = null, val thumbnailPath: String? = null, + // Present on list responses (absent in detail) — lets the channels screen + // group local channels by owner without paging the channel listing. + val account: ChannelOwnerAccount? = null, val privacy: Labelled? = null, val nsfw: Boolean = false, val views: Long = 0, 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 6d19403..02447c4 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 @@ -50,8 +50,6 @@ import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import android.util.Log -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -61,6 +59,7 @@ 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.data.api.VideoDto import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape @@ -125,6 +124,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private val repo: PeerTubeRepository = AppGraph.repository private val statsMap = HashMap() + /** SystemClock at the last successful fast-path pass — re-entry freshness. */ + private var lastLoadedAt = 0L + init { // Snapshot restore (issue #9): paint the last session's channel rows // instantly, including the per-channel stats (the "minutes of loading" @@ -141,12 +143,15 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { fun load(force: Boolean = false) { - if (!force && userRows.isNotEmpty()) return + // Re-entry (destination switch away + back) inside the freshness window: + // repaint the in-memory rows and skip the network (issue #13) — rows are + // never torn down on re-entry; an account switch builds a new ViewModel. + if (!force && userRows.isNotEmpty() && fresh()) return loading = true error = null viewModelScope.launch { try { - // The user's own row renders FIRST (fast: one call), the rest streams in. + // The user's own row first (fast: two calls). user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() @@ -154,39 +159,18 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { 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() - var start = 0L - var total: Long? = null - var retries = 0 - while (total == null || start < total) { - 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 } - 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) - } - ImagePrefetcher.channelArt(all + userChannels) + // Fast path (~3-4 requests): the local video listing already carries + // per-channel and per-account data + thumbnails, so every local row + // renders complete within seconds. The old flow walked 13 pages of + // the all-channels listing (1237 rows on this instance, most + // federated), re-sorting after every page (issue #13). + loadLocalsFromVideos(host) + loading = false - // 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) + // Background: the channel listing only refines — banners, exact + // follower counts, owner accounts for the row headers, the + // zero-video locals, and the followed-instance rows. + refineFromListing(host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -195,31 +179,107 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Re-entry skip window. */ + private fun fresh(): Boolean = + lastLoadedAt > 0 && + android.os.SystemClock.elapsedRealtime() - lastLoadedAt < 5 * 60_000L + /** - * Fetch stats for [channels] in parallel batches of 6 (each stat call walks - * up to 4 video pages — strictly sequential with ~40 channels took minutes), - * re-sorting the rows whenever a batch lands. Persisted per batch so the - * next start starts warm. + * Builds the local rows from `videos?isLocal=true` (sorted -publishedAt): + * groups per channel handle for stats (exact video count + play sum as long + * as the whole local library fits in a few pages) and per owner-account for + * the row headers. No per-channel requests at all. */ - private fun channelStatsLoop(channels: List, host: String?) { - val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } + private suspend fun loadLocalsFromVideos(host: String?) { + val videos = mutableListOf() + var start = 0L + var total: Long + do { + val page = repo.localVideos(start, 100).getOrNull() ?: break + videos += page.data + total = page.total + start += page.data.size + } while (start < total && videos.size < 2000) + + val base = AppGraph.repository + for ((key, vids) in videos.groupBy { it.channel.name + "@" + it.channel.host }) { + val first = vids.first() + statsMap[key] = ChannelStats( + videos = vids.size.toLong(), + plays = vids.sumOf { it.views }, + latestThumbUrl = first.thumbnailPath + ?.takeIf { it.isNotBlank() } + ?.let { base.absoluteUrl(it) }, + ) + if (allChannels.none { it.name == first.channel.name && it.host == first.channel.host }) { + allChannels += VideoChannelDto( + name = first.channel.name, + displayName = first.channel.displayName, + host = first.channel.host, + avatars = first.channel.avatars, + // video.account IS the channel's owner — enough for row grouping. + ownerAccount = first.account, + ) + } + } + stats = statsMap.toMap() + rebuildRows(host) + lastLoadedAt = android.os.SystemClock.elapsedRealtime() + snapshotContext?.let { ctx -> + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) + } + } + ImagePrefetcher.channelArt(allChannels + userChannels) + } + + /** + * Streams the full channel listing (thirteen 100-row pages on an instance + * following others — the locals are spread thin across them). Replaces the + * local stubs with full objects (banners + exact subscribers + owner), adds + * the zero-video locals that the video listing cannot see, and builds the + * followed-instance rows. Rebuilds only when a group actually changed. + */ + private fun refineFromListing(host: String?) { viewModelScope.launch { - missing.chunked(6).forEach { batch -> - batch.map { channel -> - async { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() - if (cs != null) Pair(key, cs) else null - } - }.awaitAll().filterNotNull().forEach { (key, cs) -> - statsMap[key] = cs + var start = 0L + var total: Long? = null + var retries = 0 + var localsFingerprint = "" + var foreignCount = -1 + while (total == null || start < total) { + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue } - stats = statsMap.toMap() - rebuildRows(host) - snapshotContext?.let { ctx -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) - } + retries = 0 + if (total == null) total = page.second + val merged = allChannels.toMutableList() + for (c in page.first) { + val existing = merged.indexOfFirst { it.name == c.name && it.host == c.host } + if (existing >= 0) merged[existing] = c + else merged += c + } + allChannels = merged + start += 100 + android.util.Log.w( + "JTube", + "listing page " + (start / 100) + ": total=" + total + " all=" + allChannels.size, + ) + // Full locals: banners/subscribers may have changed → repaint. + val fp = allChannels.filter { it.host == host } + .joinToString("|") { it.name + ":" + it.followersCount } + if (fp != localsFingerprint) { + localsFingerprint = fp + rebuildRows(host) + } + val fc = allChannels.count { it.host != host } + if (fc != foreignCount) { + foreignCount = fc + rebuildRows(host) // rebuilds both; userRows stay key-stable } } } @@ -309,7 +369,7 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(epoch) { viewModel.load(force = true) } + LaunchedEffect(epoch) { viewModel.load() } when {