diff --git a/BACKLOG.md b/BACKLOG.md index 1ee7edd..4b29b20 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -77,9 +77,4 @@ 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, - 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 + subscribing/unsubscribing, deleting, creating channels/users/playlists. \ 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 2f81d77..3138989 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,6 +125,32 @@ 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) + "/") @@ -271,9 +297,6 @@ 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/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 6ca85b5..44961b0 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,9 +50,6 @@ 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/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 5301b22..2ef6672 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,9 +150,6 @@ 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 ced63ff..43bc01c 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,13 +35,6 @@ 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 @@ -50,15 +43,10 @@ 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 @@ -78,13 +66,9 @@ private val Accent = Color(0xFFCE2B2B) @Composable fun PlayerSidebar( - mode: String, // "comments" | "playlists" | "info" + mode: String, // "comments" | "playlists" 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( @@ -94,148 +78,15 @@ fun PlayerSidebar( .background(SidebarBg), ) { Text( - text = when (mode) { - "playlists" -> "Playlists" - "info" -> "Info" - else -> "Comments" - }, + text = if (mode == "playlists") "Playlists" else "Comments", color = Color.White, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(20.dp), ) - when (mode) { - "playlists" -> PlaylistsColumn(detail, onDismiss) - "info" -> InfoColumn(detail, onDismiss, onOpenVideo, onOpenChannel) - else -> CommentsColumn(detail, onDismiss) - } + if (mode == "playlists") PlaylistsColumn(detail, onDismiss) 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) { @@ -251,17 +102,11 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) { .fillMaxWidth() .padding(20.dp) .onPreviewKeyEvent { ke -> - 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 - } + if (ke.type == KeyEventType.KeyDown && + ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT + ) { + onDismiss() + true } else false } .focusRequester(fr) @@ -269,23 +114,13 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) { ) } -/** 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. */ +/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */ @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) } @@ -294,31 +129,19 @@ private fun SidebarRow( .onFocusChanged { focused = it.isFocused } .background(if (focused) Color(0x1AFFFFFF) else Color.Transparent) if (focusRequester != null) m = m.focusRequester(focusRequester) - 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 + if (onLeft != null) { + m = m.onPreviewKeyEvent { ke -> + if (ke.type == KeyEventType.KeyDown && + ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT + ) { + onLeft() + true + } else false } } - m = if (onLongPress != null) { - m.dpadCenterTwoStage(onClick ?: {}, onLongPress) - } else { - m.dpadCenterActivatable(onClick ?: {}) - } - m = m.focusable() + m = m + .dpadCenterActivatable(onClick ?: {}) + .focusable() if (onClick != null) m = m.clickable(onClick = onClick) Row( modifier = m.padding(horizontal = 20.dp, vertical = 10.dp), @@ -389,8 +212,6 @@ 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, ) } @@ -414,16 +235,12 @@ 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) { @@ -466,34 +283,19 @@ 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 - addedOptimistically = emptySet() - removedOptimistically = emptySet() - }, + onSuccess = { containedBy = it }, onFailure = { }, ) } - fun checked(playlistId: Long): Boolean = - (containedBy.containsKey(playlistId) && playlistId !in removedOptimistically) || - playlistId in addedOptimistically - LaunchedEffect(detail.id) { repository.myPlaylists().fold( onSuccess = { playlists = it }, @@ -504,28 +306,16 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { } fun toggle(playlist: VideoPlaylistSummaryDto) { - // 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 + val elementId = containedBy[playlist.id] scope.launch { - if (inside) { - val elementId = containedBy[playlist.id] ?: return@launch - if (repository.removeFromPlaylist(playlist.id, elementId).isSuccess) { - removedOptimistically = removedOptimistically + playlist.id - } + if (elementId != null) { + repository.removeFromPlaylist(playlist.id, elementId) } else { - addedOptimistically = addedOptimistically + playlist.id - if (repository.addToPlaylist(playlist.id, detail.id).isFailure) { - addedOptimistically = addedOptimistically - playlist.id - } + repository.addToPlaylist(playlist.id, detail.id) } // Membership checks settle server-side; re-check after a moment. kotlinx.coroutines.delay(1000) refreshHits() - busy = false } } @@ -542,27 +332,6 @@ 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) @@ -585,35 +354,11 @@ 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) - .focusRequester(createBtnFocus) - .dpadCenterActivatable({ create() }), + modifier = Modifier.padding(start = 10.dp, end = 16.dp), ) { Text("Create") } } when { @@ -623,17 +368,11 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { else -> LazyColumn(Modifier.fillMaxSize()) { playlists.forEachIndexed { index, playlist -> - val checked = checked(playlist.id) + val checked = containedBy.containsKey(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) @@ -650,33 +389,6 @@ 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 @@ -685,25 +397,8 @@ private fun PlaylistCheckbox(checked: Boolean) { modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(5.dp)) - .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), - ) - } - } + .background(if (checked) Accent else Color(0x33FFFFFF)), + ) } private fun formatDateShort(iso: String?): String { 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 02447c4..6d19403 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,6 +50,8 @@ 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 @@ -59,7 +61,6 @@ 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 @@ -124,9 +125,6 @@ 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" @@ -143,15 +141,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { fun load(force: Boolean = false) { - // 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 + if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { - // The user's own row first (fast: two calls). + // 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() @@ -159,18 +154,39 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - // 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 + // 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) - // 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) + // 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" } finally { @@ -179,107 +195,31 @@ 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 - /** - * 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. + * 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. */ - 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?) { + private fun channelStatsLoop(channels: List, host: String?) { + val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) } viewModelScope.launch { - 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 + 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 } - 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 + stats = statsMap.toMap() + rebuildRows(host) + snapshotContext?.let { ctx -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) + } } } } @@ -369,7 +309,7 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(epoch) { viewModel.load() } + LaunchedEffect(epoch) { viewModel.load(force = true) } when { 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 a13eb09..b2770aa 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,7 +257,6 @@ 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 b77e291..dc9e192 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,7 +35,6 @@ 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 @@ -195,8 +194,6 @@ 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), @@ -214,7 +211,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, onOpenVideo) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel) } } @@ -277,7 +274,6 @@ private fun PlayerSurface( shortUuid: String, onBack: () -> Unit, onOpenChannel: (String, String) -> Unit, - onOpenVideo: (String) -> Unit, ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } @@ -296,14 +292,6 @@ 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) @@ -477,15 +465,12 @@ 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 (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { - if (sidebar != null) { - closeSidebar() - } else { - onBack() - } + if (sidebar != null) { + sidebar = null + runCatching { actionBarFocus.requestFocus() } + } else { + onBack() } true } @@ -566,22 +551,21 @@ private fun PlayerSurface( seekBarFocus = seekBarFocus, actionBarFocus = actionBarFocus, onOpenChannel = onOpenChannel, - openSidebar = { mode, opener -> + openSidebar = { mode -> sidebar = mode - sidebarReturn = opener }, modifier = Modifier.align(Alignment.BottomCenter), ) } - // Right sidebar (comments / playlists / info); the video is shrunk to its left. + // Right sidebar (comments / playlists); the video is shrunk to its left. sidebar?.let { mode -> PlayerSidebar( mode = mode, detail = s.detail, onDismiss = { - closeSidebar() + sidebar = null + runCatching { actionBarFocus.requestFocus() } }, - onOpenVideo = onOpenVideo, modifier = Modifier .align(Alignment.CenterEnd) .padding(top = 48.dp), @@ -591,8 +575,6 @@ 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, @@ -623,18 +605,13 @@ 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); the - // opener pill's focuser travels along so it regains focus on close. - openSidebar: (String, FocusRequester) -> Unit = { _, _ -> }, + // "comments" | "playlists" — opens the right sidebar (issue #10). + openSidebar: (String) -> 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) { @@ -749,7 +726,7 @@ private fun PausedControls( modifier = Modifier.padding(top = 12.dp), ) // Action row under the red line (issue #10): subscribe, like/dislike, - // info + comments/playlists open the right sidebar; times on the right. + // 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) } @@ -766,15 +743,13 @@ 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, - iconSize = androidx.compose.ui.unit.DpSize(33.dp, 33.dp), + flushLeft = true, focusRequester = actionBarFocus, ) { done -> scope.launch { @@ -785,18 +760,17 @@ private fun PausedControls( repository.subscribe(channelHandle).isSuccess } if (ok) { - // 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. + // Optimistic: the server's exist-check is eventually + // consistent right after a POST — trust our action, then + // re-verify a moment later. subscribed = !wasSubscribed + kotlinx.coroutines.delay(1500) + repository.subscriptionExists(channelHandle).onSuccess { subscribed = it } } done() } } PlayerActionButton( - upFocus = seekBarFocus, icon = Icons.Filled.ThumbUp, desc = "Like", count = likeCount, @@ -813,7 +787,6 @@ private fun PausedControls( } } PlayerActionButton( - upFocus = seekBarFocus, icon = Icons.Filled.ThumbDown, desc = "Dislike", count = dislikeCount, @@ -830,31 +803,18 @@ private fun PausedControls( } } PlayerActionButton( - upFocus = seekBarFocus, - icon = Icons.Filled.Info, - desc = "Info", - focusRequester = infoPillFocus, - ) { done -> - done() - openSidebar("info", infoPillFocus) - } - PlayerActionButton( - upFocus = seekBarFocus, icon = Icons.Filled.ChatBubbleOutline, desc = "Comments", - focusRequester = commentsPillFocus, ) { done -> done() - openSidebar("comments", commentsPillFocus) + openSidebar("comments") } PlayerActionButton( - upFocus = seekBarFocus, icon = Icons.Filled.PlaylistAdd, desc = "Playlists", - focusRequester = playlistsPillFocus, ) { done -> done() - openSidebar("playlists", playlistsPillFocus) + openSidebar("playlists") } Spacer(modifier = Modifier.weight(1f)) Text( @@ -1022,12 +982,10 @@ private fun PlayerActionButton( desc: String, count: Long? = null, filled: 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), + // First pill in the row: drop the left inner padding so the glyph aligns + // flush with the 24dp text margin. + flushLeft: Boolean = false, 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, @@ -1048,43 +1006,36 @@ 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( - if (focused) Color(0x55FFFFFF) else Color.Transparent, + when { + focused -> Color(0x55FFFFFF) + filled -> Color(0x33CE2B2B) + else -> Color(0x22FFFFFF) + }, 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 = m, + modifier = Modifier.padding( + start = if (flushLeft) 0.dp else 12.dp, + top = 8.dp, + end = 12.dp, + bottom = 8.dp, + ), verticalAlignment = Alignment.CenterVertically, ) { androidx.compose.material3.Icon( imageVector = icon, contentDescription = desc, - // Same red as the seek bar/playlist accent — marks active state. - tint = if (filled) VideoAccent else Color.White, - modifier = Modifier.size(iconSize), + tint = Color.White, + modifier = Modifier.size(26.dp), ) 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 c1fc078..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 @@ -1,11 +1,7 @@ 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 @@ -18,58 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent */ fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { onPreviewKeyEvent { ke -> - 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 - } + 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