diff --git a/README.md b/README.md index 07c09d8..f2add78 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,13 @@ anything that isn't public.

**Channels.** One row per owner, with video, subscriber and play counts on -every card. +every card. Open a channel for its banner, its stats and all its videos — and a +Subscribe button you can reach with the remote.

Channels screen with channel cards

-Open a channel for its banner, its stats and all its videos — with a Subscribe -button you can reach with the remote. - -

- A channel page: banner, stats, Subscribe button and the channel's videos -

- **The player** speaks remote: play and pause, seek back and forward, a seek bar with position and duration, and the video's info, comments and playlists a button away. Watching is reported to the server every 20 seconds, so where you diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index e1d9e0f..343deec 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -1,7 +1,10 @@ - + - \ No newline at end of file + diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index a9d3f43..fe13971 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -1,7 +1,6 @@ package net.jeena.jtubetv import android.app.Application -import kotlinx.coroutines.Dispatchers import coil.ImageLoader import coil.ImageLoaderFactory import net.jeena.jtubetv.data.PeerTubeRepository @@ -34,20 +33,9 @@ class JTubeApp : Application(), coil.ImageLoaderFactory { * TVs are slow compositors: decode thumbnails as RGB_565 at capped size so * scrolling rows keep a smooth frame rate (user: "the animation has very low fps"). */ - override fun newImageLoader(): ImageLoader { - // A sched trace on the TV (release build) showed the main thread being - // descheduled hundreds of times during navigation while the render - // pipeline (Mali/RenderThread/SF/Dolby) saturated all 4 cores together - // with Coil's image work — a burst of parallel 30 ms bitmap decodes - // starved the main thread for over a second (the felt "2fps"). Capping - // Coil's parallel work: images still fill in steadily while scrolling, - // but the decode/fetch pool can no longer eat every core at once. - return ImageLoader.Builder(this) + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) .crossfade(false) - .dispatcher(Dispatchers.IO.limitedParallelism(2)) - .decoderDispatcher(Dispatchers.Default.limitedParallelism(2)) - .transformationDispatcher(Dispatchers.Default.limitedParallelism(1)) .build() - } } \ No newline at end of file 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 7666884..6ca82bb 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 @@ -47,8 +47,6 @@ 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.Key -import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -192,28 +190,64 @@ private fun InfoColumn( } val descFocus = remember { FocusRequester() } - // The description owns the focus for the whole panel: UP/DOWN scroll it and are - // always consumed, so scrolling can never strand focus somewhere it can't come - // back from (issue #23 review: "it loses focus and it's impossible to scroll up"). + val firstLinkFocus = remember { FocusRequester() } + // Focus the description so the D-pad can scroll it right away (issue #23): + // a description with no links used to have nothing focusable, so a long + // text could never be scrolled down with the remote. Fall back to the + // first link when there is no description text. LaunchedEffect(Unit) { kotlinx.coroutines.delay(150) - runCatching { descFocus.requestFocus() } + runCatching { + if (description.isNotBlank()) descFocus.requestFocus() + else firstLinkFocus.requestFocus() + } } Column(Modifier.fillMaxSize()) { - if (description.isBlank()) { - SidebarFallback("No description.", onDismiss) - } else { + if (description.isNotBlank()) { DescriptionScroll( annotated = annotated, focusRequester = descFocus, onDismiss = onDismiss, - // Links stay inline in the running text (issue #23 review) instead of - // being repeated in a list underneath; OK opens the first one. - onActivate = { links.firstOrNull()?.let { open(it) } }, + onLeaveBottom = { + if (links.isNotEmpty()) { + runCatching { firstLinkFocus.requestFocus() }.isSuccess + } else false + }, modifier = Modifier.weight(1f, fill = false), ) } + if (links.isNotEmpty()) { + Text( + text = "Links", + color = TextDim, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp), + ) + LazyColumn(Modifier.heightIn(max = 220.dp)) { + links.forEachIndexed { index, url -> + item(key = "l" + index + url) { + SidebarRow( + onLeft = onDismiss, + onUp = if (index == 0 && description.isNotBlank()) { + { runCatching { descFocus.requestFocus() } } + } else null, + atTop = index == 0 && description.isBlank(), + atBottom = index == links.size - 1, + focusRequester = if (index == 0) firstLinkFocus else null, + onClick = { open(url) }, + ) { + Text( + text = linkLabel(url), + color = TextMain, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + } + } + } + } } } @@ -229,7 +263,7 @@ private fun DescriptionScroll( annotated: androidx.compose.ui.text.AnnotatedString, focusRequester: FocusRequester, onDismiss: () -> Unit, - onActivate: () -> Unit, + onLeaveBottom: () -> Boolean, modifier: Modifier = Modifier, ) { val scroll = androidx.compose.foundation.rememberScrollState() @@ -245,38 +279,21 @@ private fun DescriptionScroll( .background(if (focused) Color(0x0DFFFFFF) else Color.Transparent) .onPreviewKeyEvent { ke -> if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - // One press moves a third of a screenful, so consecutive stops - // overlap and every line of a long description gets shown (issue - // #23 review: the old 85% step jumped past the middle of the text - // on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN). - val step = (viewportPx * 0.34f).coerceAtLeast(150f) - val code = ke.nativeKeyEvent.keyCode - // Match Compose's normalised key AND the raw keycode: remotes don't all - // report the same native codes, and matching only the raw one left UP - // unhandled on some devices (issue #23 review). - val isUp = ke.key == Key.DirectionUp || code == KeyEvent.KEYCODE_DPAD_UP - val isDown = ke.key == Key.DirectionDown || code == KeyEvent.KEYCODE_DPAD_DOWN - val isLeft = ke.key == Key.DirectionLeft || code == KeyEvent.KEYCODE_DPAD_LEFT - val isCenter = ke.key == Key.DirectionCenter || ke.key == Key.Enter || - code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER - when { - // UP/DOWN are always consumed and always attempt the scroll (it - // clamps at the ends by itself), so focus stays here and the text - // can always be scrolled back up. - isDown -> { - scope.launch { scroll.animateScrollBy(step) } - true - } - isUp -> { - scope.launch { scroll.animateScrollBy(-step) } - true - } - isLeft -> { + val step = (viewportPx * 0.85f).coerceAtLeast(240f) + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> + if (scroll.value < scroll.maxValue) { + scope.launch { scroll.animateScrollBy(step) } + true + } else onLeaveBottom() + KeyEvent.KEYCODE_DPAD_UP -> + if (scroll.value > 0) { + scope.launch { scroll.animateScrollBy(-step) } + true + } else true // nothing above the description; stay put + KeyEvent.KEYCODE_DPAD_LEFT -> { onDismiss(); true } - isCenter -> { - onActivate(); true - } else -> false } } 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 1de2c73..131a64d 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 @@ -2,8 +2,6 @@ package net.jeena.jtubetv.ui.screens import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -75,9 +73,10 @@ import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Channels, SmartTube-style: one row per LOCAL user. * - * - Row 1: the logged-in user ("'s Channels") — only when they own - * at least one channel WITH videos; then the other local users by total videos. - * - Channels with no videos never render (watching-only accounts), at any level. + * - Row 1: the logged-in user ("'s Channels"), then the other local + * users sorted by videos / subscribers / plays (desc). + * - Each row: that user's channels, same ordering; channels with no videos simply + * stay inside their user's row. * - Followed-instance channels at the end, grouped per platform. * * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings @@ -128,17 +127,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { // its shimmer placeholder rows until the (fast-path, few-request) load lands, // instead of painting a stale snapshot that reshuffled as stats streamed in. - /** Guards against a second pass while the first is still running (issue #32: - * the load now starts at app launch, so opening the view can overlap it). */ - private var inFlight = false - 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 (inFlight) return if (!force && userRows.isNotEmpty() && fresh()) return - inFlight = true loading = true error = null viewModelScope.launch { @@ -167,7 +160,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { error = e.message ?: "Could not load channels" } finally { loading = false - inFlight = false } } } @@ -269,24 +261,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } private fun rebuildRowsInner(host: String?) { - // Channels without videos don't exist on this screen: plenty of local - // accounts are watching-only and their empty channels are pure noise if - // rendered (user rule). This also covers the own row — it only renders - // when the logged-in user has at least one channel with videos, and the - // group sort below still puts it first in that case. - val locals = allChannels - .filter { it.host == host && statOf(it) > 0 } + val locals = allChannels.filter { it.host == host } val ownNames = userChannels.map { c -> c.name }.toSet() val byOwner = locals.groupBy { ownerKey(it) } val groups = byOwner.values.map { row -> val sorted = row.sortedWith( - // Stable keys only: all of these come from the video listing and - // never change mid-load — subscriber counts arrive later per- - // channel and must not reorder anything while loading (rows that - // jump while stats stream in read as broken). compareByDescending { statOf(it) } - .thenByDescending { playsOf(it) } - .thenByDescending { it.followersCount }, + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, ) val own = row.firstOrNull { it.name in ownNames } UserGroup( @@ -299,7 +281,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { userRows = groups.sortedWith( compareByDescending { g -> g.channels.any { it.name in ownNames } } .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } - .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.plays ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) Log.w( @@ -329,8 +310,6 @@ fun ChannelsScreen( epoch: Int = 0, onOpenDrawer: () -> Unit = {}, drawerPulse: Int = 0, - /** Channel opened last — gets the focus back on return (issue #34). */ - focusHandle: String? = null, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { LaunchedEffect(epoch) { viewModel.load() } @@ -338,18 +317,21 @@ fun ChannelsScreen( when { viewModel.loading && viewModel.userRows.isEmpty() -> - // One row header stub + a single loading card, not a fake grid (#31). + // Placeholder rows while the channels load (issue #19): header stub + // + a row of shimmer card-shaped placeholders, twice. androidx.compose.foundation.layout.Column { - Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) { - Box( - Modifier - .width(180.dp) - .height(22.dp) - .clip(CircleShape) - .background(Color(0xFF232323)), - ) + repeat(2) { + Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) { + Box( + Modifier + .width(180.dp) + .height(22.dp) + .clip(CircleShape) + .background(Color(0xFF232323)), + ) + } + ShimmerRow() } - ShimmerRow(size = 1) } viewModel.error != null && viewModel.userRows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -370,14 +352,6 @@ fun ChannelsScreen( // as stats stream in the rows re-sort, and re-focusing their first card // would steal focus mid-navigation (user: "up jumps to the first row"). val focusHandedOut = remember { mutableStateOf(false) } - // The channel we came back from only wins the focus while it is still - // in the rows. When it isn't (renamed, unsubscribed, or the rows simply - // never carried it) every row used to ask for no focus at all — "when I - // click on Channels, none is selected" (#32 review). Fall back to the - // first row in that case, like the video cards do. - val focusRowExists = focusHandle != null && viewModel.userRows.any { - group -> group.channels.any { it.name + "@" + it.host == focusHandle } - } LazyColumn(Modifier.fillMaxSize()) { viewModel.userRows.forEach { group -> item(key = "hdr-" + group.ownerName) { @@ -387,22 +361,13 @@ fun ChannelsScreen( ) } item(key = "row-" + group.ownerName) { - // Coming back from a channel page, focus the card we opened - // instead of the very first one (issue #34). - val target = group.channels.indexOfFirst { - it.name + "@" + it.host == focusHandle - } - val isTargetRow = target >= 0 ChannelRow( group.channels, onOpenChannel, onOpenDrawer, drawerPulse = drawerPulse, - focused = !focusHandedOut.value && ( - if (focusRowExists) isTargetRow - else group == viewModel.userRows.firstOrNull() - ), - focusIndex = if (isTargetRow) target else 0, + focused = !focusHandedOut.value && + group == viewModel.userRows.firstOrNull(), onFocused = { focusHandedOut.value = true }, stats = { viewModel.stats[it.name + "@" + it.host] }, platformName = { viewModel.platformNames[it.host ?: ""] }, @@ -457,8 +422,6 @@ private fun ChannelRow( onOpenDrawer: () -> Unit = {}, drawerPulse: Int = 0, focused: Boolean, - /** Which card to hand the initial focus to (issue #34). */ - focusIndex: Int = 0, onFocused: () -> Unit = {}, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformName: (VideoChannelDto) -> String?, @@ -481,39 +444,21 @@ private fun ChannelRow( val notFullyVisible = target == null || target.offset < 0 || target.offset + target.size > rowState.layoutInfo.viewportEndOffset - if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } if (focused) { - LaunchedEffect(channels.isNotEmpty(), focusIndex) { + LaunchedEffect(channels.isNotEmpty()) { if (channels.isNotEmpty()) { - val target = focusIndex.coerceIn(0, channels.size - 1) - // Bring it on screen first; a card further right isn't composed yet, - // so retry until its focus requester is attached (issue #34). - if (target > 0) runCatching { rowState.scrollToItem(target) } - repeat(8) { - delay(80) - val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull() - if (ok != null) { - onFocused() - return@LaunchedEffect - } - } - // The wanted card never became focusable (its row scrolled past it, - // or the handle went stale): take the first card instead, so the - // screen is never left with nothing selected (#32 review). - val first = focusRequesters[0] - if (first != null && runCatching { first.requestFocus() }.isSuccess) onFocused() + if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() } } } LazyRow( state = rowState, - // Land on the last-focused card when moving between rows (issue #29). No - // custom fallback: a requester whose card has scrolled out of composition - // crashed with "FocusRequester is not initialized". - modifier = Modifier.focusRestorer(), + // Land on the last-focused card when moving between rows (issue #29). + modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -586,19 +531,13 @@ fun ChannelCard( val coverUrl = banner?.let { absoluteBanner(it) } ?: stats?.latestThumbUrl if (coverUrl != null) { - // The request must be remembered: ImageRequest has no value equality, - // a fresh request per recomposition reset the shimmer (and retried - // the load) every frame — the fps regression on the TV. - val ctx = androidx.compose.ui.platform.LocalContext.current - val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host } - val coverReq = remember(coverKey, coverUrl) { - coil.request.ImageRequest.Builder(ctx) + ShimmerAsyncImage( + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(coverUrl) .size(480, 270) // same key as prefetch - .build() - } - ShimmerAsyncImage( - model = coverReq, + .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -606,18 +545,13 @@ fun ChannelCard( } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { - val ctx = androidx.compose.ui.platform.LocalContext.current - val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) } - val avatarReq = remember(avatarUrl) { - coil.request.ImageRequest.Builder( - ctx, - ) - .data(avatarUrl) - .size(80, 80) - .build() - } ShimmerAsyncImage( - model = avatarReq, + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80, 80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, base = Color(0xFF333333), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 53d8625..7ebda20 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -1,7 +1,5 @@ package net.jeena.jtubetv.ui.screens -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -453,17 +450,15 @@ fun FocusScrollRow( val notFullyVisible = target == null || target.offset < 0 || target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset - if (notFullyVisible) rowState.easedScrollToItem(focusedIndex) + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } LazyRow( state = rowState, // Jumping between rows lands on the card this row last had focus on, not // the geometrically-nearest one (issue #29): the rows stop shifting - // left/right as you move up and down between them. No custom fallback — - // handing back a requester whose card has scrolled out of composition - // crashed with "FocusRequester is not initialized". - modifier = Modifier.focusRestorer(), + // left/right as you move up and down between them. + modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -498,19 +493,3 @@ fun FocusScrollRow( } } } - -/** - * Eased scroll to [index] with a capped duration (perf): [animateScrollToItem]'s - * default spec keeps re-ticking for hundreds of ms on long distances, and every - * tick is a full-window redraw on a TV GPU — which is what dragged the scroll - * down to a few fps. A short tween keeps the eased feel at a fraction of the - * ticks. (Shared by the home rows and the channels rows.) - */ -suspend fun LazyListState.easedScrollToItem(index: Int) { - val info = layoutInfo - val first = info.visibleItemsInfo.firstOrNull() ?: return - val stride = first.size + info.mainAxisItemSpacing - if (stride <= 0) return - val delta = index * stride - (first.index * stride + firstVisibleItemScrollOffset) - animateScrollBy(delta.toFloat(), tween(140)) -} 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 1e28d40..019e2ff 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 @@ -87,22 +87,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var channelName by rememberSaveable { mutableStateOf(null) } // display name - // Last channel opened from the Channels screen — refocused on return (#34). - var lastOpenedChannel by rememberSaveable { mutableStateOf(null) } - // Channels are expensive to assemble, so start that work at app launch rather - // than when the view is first opened (issue #32); the view then usually finds - // the rows already there, and load() no-ops while fresh. - val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel( - key = "channels$epoch", - factory = ChannelsViewModel.factory, - ) - LaunchedEffect(epoch) { channelsViewModel.load() } // NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID // Non-null while the playing video lives on ANOTHER instance (vidiverse results). var playingVideoHost by rememberSaveable { mutableStateOf(null) } - // Thumbnail of the opened card — shown as the player's poster while it loads. - var playingVideoThumb by rememberSaveable { mutableStateOf(null) } var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current @@ -187,8 +175,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/') val host = video.channel.host playingVideoHost = if (host != null && host != localHost) host else null - playingVideoThumb = video.thumbnailUrl - ?: video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { AppGraph.repository.absoluteUrl(it) } playingVideo = video.shortUUID } Box(modifier = Modifier.fillMaxSize()) { @@ -230,16 +216,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi drawerPulse = drawerPulse, ) Destination.Channels -> ChannelsScreen( - onOpenChannel = { handle -> - channelDetails = handle - lastOpenedChannel = handle - }, + onOpenChannel = { handle -> channelDetails = handle }, epoch = epoch, onOpenDrawer = { drawerOpen = true }, drawerPulse = drawerPulse, - // Back from a channel page re-selects it (issue #34). - focusHandle = lastOpenedChannel, - viewModel = channelsViewModel, ) Destination.Search -> VideoListScreen( @@ -293,15 +273,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi PlayerScreen( shortUuid = playingVideo!!, sourceHost = playingVideoHost, - posterUrl = playingVideoThumb, onBack = { playingVideo = null }, onOpenChannel = { handle, displayName -> playingVideo = null channelDetails = handle channelName = displayName }, - // A link-opened video has no card thumbnail to pose with. - onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null }, + onOpenVideo = { playingVideo = it; playingVideoHost = null }, ) } } 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 310584c..3ba847b 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 @@ -207,9 +207,6 @@ fun PlayerScreen( onBack: () -> Unit, /** Set when the video lives on another instance (vidiverse search results). */ sourceHost: String? = null, - /** Thumbnail of the opened card, shown full-screen while the metadata loads so - * the player doesn't flash black before buffering starts (user report). */ - posterUrl: String? = null, // From the info sidebar's local PeerTube video links: switches the player over. onOpenVideo: (String) -> Unit = {}, // From the paused/controls overlay's channel icon: (handle, displayName), @@ -230,27 +227,15 @@ fun PlayerScreen( } when (val s = state) { - is PlayerUiState.Loading -> PlayerLoading(posterUrl) + is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo) } } @Composable -private fun PlayerLoading(posterUrl: String? = null) { +private fun PlayerLoading() { Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { - // Show the card's thumbnail (dimmed) while the metadata resolves, so the - // player opens onto the video's own poster instead of plain black; it - // hands off seamlessly to the buffering thumbnail once the stream is ready. - if (posterUrl != null) { - coil.compose.AsyncImage( - model = posterUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - Box(Modifier.fillMaxSize().background(Color(0xB3000000))) - } CircularProgressIndicator() } } @@ -323,10 +308,6 @@ private fun PlayerSurface( // 200 ms: quick flaps cancel the pending change and the overlay stays // composed, so the animation never restarts mid-playback. var bufferingPending by remember { mutableStateOf(true) } - // STATE_READY can fire before the first frame is actually drawn, leaving a black - // gap between the thumbnail and the picture. Keep the thumbnail until the first - // frame really renders (user report: "thumbnail, then black, then playing"). - var firstFrame by remember { mutableStateOf(false) } LaunchedEffect(bufferingPending) { kotlinx.coroutines.delay(200) buffering = bufferingPending @@ -438,14 +419,7 @@ private fun PlayerSurface( } override fun onPlaybackStateChanged(playbackState: Int) { - // Only real buffering shows the overlay (issue #33): STATE_ENDED used - // to count as "not ready" and brought the thumbnail + spinner back - // over the last frame. The initial load is covered by !firstFrame. - bufferingPending = playbackState == Player.STATE_BUFFERING - } - - override fun onRenderedFirstFrame() { - firstFrame = true + bufferingPending = playbackState != Player.STATE_READY } }) onDispose { @@ -599,7 +573,7 @@ private fun PlayerSurface( // ready to render — metadata resolution alone (the Loading state) is not // enough (issue #8). Fullscreen loading shows the video's own thumbnail // dimmed under the circle loader (issue #19) instead of plain black. - if (buffering || !firstFrame) { + if (buffering) { Box( Modifier .fillMaxSize() @@ -646,9 +620,9 @@ private fun PlayerSurface( closeSidebar() }, onOpenVideo = onOpenVideo, - // Full height from the very top — the old top padding left a black - // strip above the panel (issues #23/#24). - modifier = Modifier.align(Alignment.TopEnd), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 48.dp), ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index e910f5b..0a1c3a2 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -124,18 +124,11 @@ fun VideoCard( .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> - // The request object must be remembered: ImageRequest has no value - // equality, and a fresh request per recomposition reset the - // shimmer's loaded state (and retried the load) on every frame — - // the fps regression on the TV. - val ctx = androidx.compose.ui.platform.LocalContext.current - val req = remember(video.id, video.thumbnailUrl) { - coil.request.ImageRequest.Builder(ctx) - // search-index results carry an absolute URL for their own instance - .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path)) - .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs - .build() - } + val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + // search-index results carry an absolute URL for their own instance + .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path)) + .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs + .build() ShimmerAsyncImage( model = req, contentDescription = video.name, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 8e06582..cedb561 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -1,6 +1,5 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Check @@ -74,42 +73,10 @@ fun VideoListScreen( ), ) { val items = viewModel.items - // per-item focus requesters so BACK from the player can refocus the opened card. - // A plain HashMap, like the rows: this is/was written during composition of - // every card, and a snapshot map here re-invalidated the reading scopes on - // each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5). - // Keyed only on [epoch]: on items.size the map was dropped wholesale, and - // already-composed cards didn't re-populate it until their next recompose. - val requesters = androidx.compose.runtime.remember(epoch) { - HashMap() + // per-item focus requesters so BACK from the player can refocus the opened card + val requesters = androidx.compose.runtime.remember(items.size, epoch) { + androidx.compose.runtime.mutableStateMapOf() } - // Scroll position survives destination switches (per source), not just player exits. - val gridState = androidx.compose.runtime.saveable.rememberSaveable( - inputs = arrayOf(epoch), - key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, - ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - - if (source is ListSource.Channel) { - // Fetch the banner/avatar/counts for the channel page header. - LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } - // Park the grid at the top as soon as the page has content, BEFORE the - // first card takes focus (the effect below). It used to run after it: - // handing the first card the focus animates the grid up (a focused card - // that sits partly off-screen is pulled into view), and this effect then - // snapped it back down — the jump that was still reported on the channel - // page when the first video got focus (#27 review). - // The park has to be unconditional, and has to reset the row's offset, - // not just its index: the focus pull leaves the header row at index 0 but - // slid up by ~96 dp, so a guard on firstVisibleItemIndex > 0 never fires - // in that state and the top of the channel header stays clipped off - // screen (#31 review). scrollToItem(0) re-anchors both. - LaunchedEffect(items.isNotEmpty(), source) { - if (items.isNotEmpty()) { - runCatching { gridState.scrollToItem(0) } - } - } - } - // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } // Which card was opened last — re-focus it when playback ends (UX report BUG-3). @@ -118,9 +85,6 @@ fun VideoListScreen( // With a running search the first RESULT gets focus; without one the // field does (the SearchHeader's own effect) — never both (issue #20). if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) { - // The channel page parks its grid at the top just above; wait for that - // beat so the focus handover can never leave a scroll to undo (#27). - if (source is ListSource.Channel) kotlinx.coroutines.delay(250) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } } } @@ -135,6 +99,17 @@ fun VideoListScreen( lastOpenedIndex = -1 } } + // Scroll position survives destination switches (per source), not just player exits. + val gridState = androidx.compose.runtime.saveable.rememberSaveable( + inputs = arrayOf(epoch), + key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + ) { androidx.compose.foundation.lazy.grid.LazyGridState() } + + if (source is ListSource.Channel) { + // Fetch the banner/avatar/counts for the channel page header. + LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } + } + // Row starts of the grid: LEFT from a row's first card opens the drawer // (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with // zero spacing, and the content pane eats 40dp (8 start + 32 end), so the @@ -187,27 +162,29 @@ fun VideoListScreen( return } + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) { + // Channel page: the header is PINNED above the grid (issue #27) rather than + // scrolling away as the first grid item — auto-focusing the first video card + // no longer hides the banner, and the Subscribe button on it stays reachable. + if (source is ListSource.Channel) { + ChannelHeader( + channelHeader = viewModel.channelHeader, + videoTotal = viewModel.videoTotal, + handle = source.handle, + onLeftEdge = onOpenDrawer, + ) + } LazyVerticalGrid( // Same card size as the Home rows: ~4 fit per row (user request). columns = GridCells.Adaptive(rowCardWidth() - 12.dp), state = gridState, - modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp), ) { - // Page headers scroll WITH the content (issue #27 review: a pinned header - // stayed put and the videos slid behind it). The header is compact enough - // that it's still fully visible when the first card takes focus. - if (source is ListSource.Search || source is ListSource.Channel) { + // Search field still scrolls with its results (its own header item); + // the channel banner is pinned above the grid instead (issue #27). + if (source is ListSource.Search) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - when (source) { - is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer) - is ListSource.Channel -> ChannelHeader( - channelHeader = viewModel.channelHeader, - videoTotal = viewModel.videoTotal, - handle = source.handle, - onLeftEdge = onOpenDrawer, - ) - else -> {} - } + SearchHeader(viewModel, onOpenDrawer) } } if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { @@ -232,14 +209,21 @@ fun VideoListScreen( // Placeholder cards while the page's first chunk loads (issue #19): // shaped like the real cards, with the faint shimmer, on every // VideoListScreen (My videos, Search, Subscriptions, History, channel pages). - item(key = "initial-placeholders") { + item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { LaunchedEffect(source) { viewModel.loadMore(force = true) } - // A single loading card, not a full fake grid (issue #31). - ShimmerCard( - modifier = Modifier - .padding(6.dp) - .width(rowCardWidth() - 12.dp), - ) + androidx.compose.foundation.layout.Column { + repeat(2) { + androidx.compose.foundation.layout.Row { + repeat(cardsPerRow) { + ShimmerCard( + modifier = Modifier + .padding(6.dp) + .width(rowCardWidth()), + ) + } + } + } + } } } itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> @@ -276,6 +260,7 @@ fun VideoListScreen( } } } + } } @Composable @@ -421,11 +406,8 @@ private fun ScopeChip( /** * Channel page header (issue #27): the banner is the backdrop of a fixed-height * header, darkened with a bottom-up gradient, with the channel identity + stats - * and a reachable Subscribe button laid over it. It scrolls with the video grid. - * - * Its height is the same before and after the channel loads, so the cards never - * get pushed down when it arrives (issue #27 review), and a channel with no - * banner gets a colour gradient derived from its handle instead of flat black. + * and a reachable Subscribe button laid over it. It's pinned above the grid so + * it never scrolls away, and the video cards start directly beneath it. */ @Composable private fun ChannelHeader( @@ -434,10 +416,10 @@ private fun ChannelHeader( handle: String, onLeftEdge: () -> Unit = {}, ) { + if (channelHeader == null) return val metaColor = Color(0xFFCFCFCF) - val banner = channelHeader?.banners?.maxByOrNull { it.width } - val description = channelHeader?.description?.takeIf { it.isNotBlank() } - val accent = remember(handle) { channelAccent(handle) } + val banner = channelHeader.banners.maxByOrNull { it.width } + val description = channelHeader.description?.takeIf { it.isNotBlank() } androidx.compose.foundation.layout.Column( Modifier .fillMaxWidth() @@ -445,15 +427,14 @@ private fun ChannelHeader( .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) .background(Color.Black), ) { - // Banner area: 116 dp, with the identity + stats anchored to its bottom; the - // description sits in the black strip below it. The whole band is 160 dp - // (4 + 116 + 40) — under one card row — so the grid never reflows around it - // and the band stays fully on screen when the first card takes focus (#31). + // Banner area: a fixed height with the identity + stats anchored to its + // bottom. A long description flows into the black strip BELOW it rather than + // being pushed up over the banner or clipped (user request). Box( Modifier .fillMaxWidth() - .height(116.dp) - .background(androidx.compose.ui.graphics.Brush.linearGradient(accent)), + .height(178.dp) + .background(Color(0xFF1C1C1C)), ) { if (banner != null) { ShimmerAsyncImage( @@ -477,9 +458,7 @@ private fun ChannelHeader( ), ) // Identity + stats, bottom-left. The avatar sits beside the title (as it - // did before the redesign — user request). Only once the channel loaded; - // the box above keeps the height reserved in the meantime. - if (channelHeader != null) { + // did before the redesign — user request). Column( modifier = Modifier .align(Alignment.BottomStart) @@ -497,22 +476,6 @@ private fun ChannelHeader( .size(56.dp) .clip(androidx.compose.foundation.shape.CircleShape), ) - } else { - // A channel with no icon gets a stand-in, not a gap (#27 review). - Box( - Modifier - .size(56.dp) - .clip(androidx.compose.foundation.shape.CircleShape) - .background(Color(0x40FFFFFF)), - contentAlignment = Alignment.Center, - ) { - androidx.compose.material3.Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = null, - tint = Color(0xFFE8E8E8), - modifier = Modifier.size(42.dp), - ) - } } Column(modifier = Modifier.padding(start = 14.dp)) { Text( @@ -567,42 +530,22 @@ private fun ChannelHeader( .align(Alignment.TopEnd) .padding(16.dp), ) - } } - // Description strip in the black beneath the banner. Fixed height so the - // header measures the same before and after the channel loads (#27 review). - Box( - Modifier - .fillMaxWidth() - .height(40.dp) - .padding(start = 16.dp, end = 16.dp, top = 6.dp), - ) { - if (description != null) { - Text( - text = description, - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - ) - } + // Description in the black beneath the banner: a long one grows the header + // downward, fading out of the banner that already darkens into black. + if (description != null) { + Text( + text = description, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp), + ) } } } -/** - * Deterministic backdrop colours for a channel without a banner (#27 review): - * the handle's hash picks a hue, so every channel keeps the same recognisable - * gradient instead of a flat black header. - */ -private fun channelAccent(handle: String): List { - val hue = ((handle.hashCode() ushr 8) % 360 + 360) % 360 - return listOf( - Color.hsv(hue.toFloat(), 0.45f, 0.42f), - Color.hsv(((hue + 38) % 360).toFloat(), 0.55f, 0.16f), - ) -} - /** * Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the * player's action: the server's follow-exists check can lag for federated diff --git a/docs/screenshots/channel.png b/docs/screenshots/channel.png deleted file mode 100644 index d860148..0000000 Binary files a/docs/screenshots/channel.png and /dev/null differ diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png index 230212d..f88dc4e 100644 Binary files a/docs/screenshots/channels.png and b/docs/screenshots/channels.png differ diff --git a/docs/screenshots/drawer.png b/docs/screenshots/drawer.png index 986b103..5559078 100644 Binary files a/docs/screenshots/drawer.png and b/docs/screenshots/drawer.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index 8ddd367..1e150b8 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/myvideos.png b/docs/screenshots/myvideos.png index 251714c..5321a83 100644 Binary files a/docs/screenshots/myvideos.png and b/docs/screenshots/myvideos.png differ diff --git a/docs/screenshots/player.png b/docs/screenshots/player.png index 5069216..f722916 100644 Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index a4a81d7..fdb9af7 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -15,14 +15,6 @@ set -euo pipefail SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" -# Prefer the working host SDK at /opt/android-sdk: the emulator that -# ~/Android/Sdk auto-updated to on Oct 11 (37.2.12) fails its own KVM probe -# ("/dev/kvm is not found") with KVM perfectly healthy — /opt/android-sdk and -# the container-home copy both pass -accel-check on the same kernel, so that -# install is broken. /opt/android-sdk is the documented host SDK anyway. -if [ -z "${ANDROID_SDK_ROOT:-}" ] && [ -x /opt/android-sdk/emulator/emulator ]; then - SDK=/opt/android-sdk -fi EMU="$SDK/emulator/emulator" ADB="$SDK/platform-tools/adb" SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager" @@ -84,41 +76,6 @@ if [ -e /dev/kvm ]; then echo "/dev/kvm found — hardware acceleration on" fi -# Pin the emulator process to the integrated GPU so the discrete card stays free -# for AI inference (windowed mode uses the host GL/Vulkan stack; headless runs on -# swiftshader — pure software — and never touches any GPU at all). Discovered -# from the PCI tree: first display device NOT driven by the nvidia driver. -IGPU="" -for dev in /sys/bus/pci/devices/*; do - class=$(cat "$dev/class" 2>/dev/null) || continue - case "$class" in - 0x0300*|0x0380*) ;; # VGA / display controller - *) continue ;; - esac - if [ "$(basename "$(readlink "$dev/driver" 2>/dev/null)")" = nvidia ]; then continue; fi - IGPU=$(basename "$dev") - break -done -if [ -n "$IGPU" ]; then - # Mesa GL/GLES: force the Mesa vendors, then the Intel render device. - export __GLX_VENDOR_LIBRARY_NAME=mesa - export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json - export DRI_PRIME="pci-${IGPU//./_}" - # Vulkan: restrict the loader to the iGPU's ICD (both var names — loader - # version differences). - for icd in /usr/share/vulkan/icd.d/intel_icd.x86_64.json \ - /usr/share/vulkan/icd.d/Intel_icd.x86_64.json; do - if [ -f "$icd" ]; then - export VK_ICD_FILENAMES="$icd" - export VK_DRIVER_FILES="$icd" - break - fi - done - echo "GPU pinned to iGPU $IGPU (Mesa/Intel env) — dGPU untouched" -else - echo "no iGPU found — leaving GPU selection to the driver (dGPU may be used)" -fi - # --- build combined adb vendor-keys file ------------------------------------ # The TV system image is a user build: adb push to /data/misc/adb/adb_keys is # blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead. diff --git a/tools/mock/README.md b/tools/mock/README.md new file mode 100644 index 0000000..5104500 --- /dev/null +++ b/tools/mock/README.md @@ -0,0 +1,29 @@ +# Screenshot mock backend + +Throwaway tooling to regenerate the README screenshots without exposing the +private family content on the real instance. Lives on the `tools/screenshot-mock` +branch only — `main` never carries the mock or the cleartext flag. + +`mock_peertube.py` is a thin HTTP proxy to the public PeerTube demo instance +(`peertube2.cpy.re`, real CC/Blender content) with a handful of overrides so the +authenticated screens are populated without an account (login always succeeds, a +"Demo User" with an avatar, Continue watching / My videos / Subscriptions each +show a different slice so thumbnails don't repeat, My videos gets privacy badges, +and a channel page shows the full video grid). Media and images still load +straight from peertube2 over HTTPS; only the JSON and thumbnails pass through the +proxy. The app itself runs unmodified — the only app-side change is the +debug-only `android:usesCleartextTraffic="true"` in `app/src/debug/` that lets a +debug build reach the proxy over HTTP. + +## Regenerate the screenshots + +1. `git checkout tools/screenshot-mock` +2. Start the proxy on the host: `python3 tools/mock/mock_peertube.py` (port 8080; + needs only Python 3 stdlib). +3. Build + install the debug build on the emulator: + `./gradlew :app:assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk` +4. On the setup screen enter `http://10.0.2.2:8080` (the emulator's alias for the + host) and sign in with any username/password. +5. Drive the UI with `adb shell input …` and `adb shell screencap` to capture + `home`, `drawer`, `myvideos`, `channels`, `player`, and a channel page. +6. Commit the PNGs on `main` (only the images), not here. diff --git a/tools/mock/mock_peertube.py b/tools/mock/mock_peertube.py new file mode 100644 index 0000000..6f2235e --- /dev/null +++ b/tools/mock/mock_peertube.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Mock PeerTube backend for README screenshots. + +It is a thin HTTP proxy to the public PeerTube demo instance +(peertube2.cpy.re, real CC/Blender content) with a few overrides so the +authenticated screens are populated without a real account: + + - login always succeeds (oauth-clients/local + users/token are canned), + - /users/me is a "Demo User" with an avatar, + - Continue watching / My videos / Subscriptions each proxy a DIFFERENT + window of the public video list, so the rows don't repeat thumbnails, + - My videos gets Private/Internal/Public badges, + - a channel page shows the full video grid (channel .../videos is widened), + - a video opened from Continue watching gets a fake resume position. + +Everything else (video lists, video detail with its absolute HLS URLs, channel +banners/avatars, config, thumbnails) is proxied verbatim, so media and images +load straight from peertube2 over HTTPS; only the JSON + thumbnails pass through +here. The app runs UNMODIFIED against it (a debug-only cleartext flag in +app/src/debug lets it talk HTTP to this proxy). + +Usage: + python3 tools/mock/mock_peertube.py # listens on 0.0.0.0:8080 +Then in the app's setup screen use http://10.0.2.2:8080 (emulator) and sign +in with any username/password. +""" +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://peertube2.cpy.re" +UP_HOST = "peertube2.cpy.re" +AVATAR = UPSTREAM + "/lazy-static/avatars/73e78544-cbae-439e-8c84-98b0756fbde7.jpg" +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 +# How the emulator addresses this proxy. The app decides a video/channel is +# "local" by matching its host against the instance host, so we relabel the +# upstream host to ours in the JSON (only the `host` fields, never URLs, so +# thumbnails/media keep pointing at peertube2). +MOCK_HOST = f"10.0.2.2:{PORT}" + + +def relabel_hosts(obj): + if isinstance(obj, dict): + return {k: (MOCK_HOST if k == "host" and v == UP_HOST else relabel_hosts(v)) for k, v in obj.items()} + if isinstance(obj, list): + return [relabel_hosts(x) for x in obj] + return obj + + +def fetch(path_with_query): + req = urllib.request.Request(UPSTREAM + path_with_query, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, r.headers.get_content_type(), r.read() + + +def fetch_videos(start, count, sort="-publishedAt"): + _, _, body = fetch(f"/api/v1/videos?isLocal=true&start={start}&count={count}&sort={sort}") + return json.loads(body) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # quiet + pass + + def _json(self, obj, code=200): + data = json.dumps(relabel_hosts(obj)).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _raw(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _q(self, query, name, default): + return urllib.parse.parse_qs(query).get(name, [default])[0] + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + self.rfile.read(int(self.headers.get("Content-Length", 0) or 0)) + if path == "/api/v1/users/token": + return self._json({ + "token_type": "Bearer", "access_token": "demo", + "refresh_token": "demo", "expires_in": 86400, + }) + if re.match(r"^/api/v1/videos/[^/]+/watching$", path): + self.send_response(204) + self.end_headers() + return + self._json({}, 204) + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + path, query = parsed.path, parsed.query + start = lambda off: int(self._q(query, "start", "0")) + off # noqa: E731 + count = int(self._q(query, "count", "25")) + + if path == "/api/v1/oauth-clients/local": + return self._json({"client_id": "demo", "client_secret": "demo"}) + if path == "/api/v1/users/me": + return self._json({ + "username": "demo", + "account": {"displayName": "Demo User", + "avatars": [{"width": 120, "fileUrl": AVATAR}]}, + }) + if path == "/api/v1/config": + return self._json({ + "instance": {"name": "PeerTube Demo", + "shortDescription": "Public demo content"}, + "search": {"searchIndex": {"enabled": False}}, + }) + if path == "/api/v1/users/me/videos": + data = fetch_videos(start(4), count) + for i, v in enumerate(data.get("data", [])): + v["privacy"] = {0: {"id": 3, "label": "Private"}, + 1: {"id": 4, "label": "Internal"}}.get(i % 3, {"id": 1, "label": "Public"}) + return self._json(data) + if path == "/api/v1/users/me/history/videos": + return self._json(fetch_videos(start(8), count)) + if path == "/api/v1/users/me/subscriptions/videos": + return self._json(fetch_videos(start(16), count)) + if path.startswith("/api/v1/users/me/subscriptions/exist"): + return self._json({}) + if re.match(r"^/api/v1/video-channels/[^/]+/videos$", path): + return self._json(fetch_videos(start(0), count)) # full grid on the channel page + m = re.match(r"^/api/v1/videos/([^/]+)$", path) + if m and self._q(query, "userHistory", "") == "true": + _, _, body = fetch(path) + detail = json.loads(body) + detail["userHistory"] = {"currentTime": int(detail.get("duration", 0) * 0.35)} + return self._json(detail) + + # Everything else (video lists, detail, channels, thumbnails) is proxied. + # A handle the app built with our host (name@10.0.2.2:PORT) is mapped back + # to the upstream host before we ask peertube2. + upstream = self.path.replace(MOCK_HOST, UP_HOST).replace( + MOCK_HOST.replace(":", "%3A"), UP_HOST) + try: + code, ctype, body = fetch(upstream) + if "json" in ctype: + self._json(json.loads(body), code) # relabel hosts in proxied JSON too + else: + self._raw(code, ctype, body) + except urllib.error.HTTPError as e: + self._raw(e.code, "application/json", e.read()) + except Exception as e: # noqa: BLE001 + self._raw(502, "text/plain", str(e).encode()) + + +if __name__ == "__main__": + print(f"Mock PeerTube proxy on http://0.0.0.0:{PORT} -> {UPSTREAM}") + print(f"Emulator: set instance URL to http://10.0.2.2:{PORT} and sign in with anything.") + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()