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/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/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/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 { 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