Compare commits

..

11 commits

Author SHA1 Message Date
671d2dd661 tools: relabel upstream host in the mock so the Channels screen populates
The app decides a video/channel is "local" by matching its host against the
instance host; relabel peertube2.cpy.re to the proxy's own host in the JSON
(host fields only, never URLs) and map handles back when proxying, so the
Channels screen and channel pages work through the mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 13:06:44 +00:00
35f2633fa5 tools: screenshot mock backend (branch-only)
A thin HTTP proxy to the public PeerTube demo instance with overrides so the
authenticated screens populate without an account, for regenerating the README
screenshots without the private instance. Includes a debug-only cleartext flag
so a debug build can reach the proxy over HTTP. Kept on this branch; main stays
clean. See tools/mock/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:58:09 +00:00
e9d7b473fb docs: refresh the README screenshots (#30)
New screenshots of the current UI (title bar with the account on the right, the
redesigned Home with three rows and progress bars, My videos privacy badges, the
Channels rows and the player). Captured against the public PeerTube demo instance
(peertube2.cpy.re) so they show real CC/Blender content, as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:37:51 +00:00
e8117ab08e docs: rewrite the README for humans, add the PeerTube logo (#30)
Rewrite the README so it leads with what JTube TV is and who it's for, refresh
the feature list and screenshot captions to match the current UI (three Home
rows, Back opens the sidebar, Subscribe on the channel page), and add the
official PeerTube logo next to the name. Screenshot images are refreshed in a
follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:49 +00:00
069dfe38c4 docs: remove BACKLOG.md
Future work is tracked as Forgejo issues now; the standalone backlog file was
redundant. AGENTS.md and README.md no longer reference it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:25 +00:00
39d3b12182 channel: redesign the header, reachable Subscribe button (#27)
Without a mouse the channel banner was invisible: opening a channel focused the
first video card, which scrolled the header (a grid item) off-screen. The header
is now pinned above the grid and never scrolls away. The banner is its backdrop,
darkened to black at the bottom; the avatar sits beside the title with a
"by <owner> on <platform>" byline, the subscriber/video/created stats, and the
description flowing into the black beneath (a long one grows the header down
instead of being clipped). A Subscribe button sits on the banner, reachable with
UP from the first card, wired to the same follow/unfollow as the player.

AGENTS.md: subscribing is in scope (already on the player, now the channel page
too), so drop it from the excluded list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
84a0527e20 images: shimmer placeholder while thumbnails and art load (#28)
Thumbnails, channel banners and avatars popped in from a flat grey rectangle. A
new ShimmerAsyncImage shows the same faint shimmer as the card placeholders
while the image loads, and the sweep disposes once the image (or an error)
arrives. Used for video thumbnails and the channel banners/avatars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
4176edfd69 loading: drop the snapshot cache, one loading card per thing (#25)
The saved-list snapshot (issue #9) painted the last session's rows instantly,
then the network refresh swapped them in and the tiles jumped as newer videos
appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and
the Channels screen; images stay fast because Coil already disk-caches them.

Screens now show the loading card while the first page loads instead of stale
content: one card per Home row (not a fake full row), a single card at the end
of a growing grid, and a trailing card when a Home row loads its next page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
e485caa855 rows: restore the last-focused card when moving between rows (#29)
Moving up/down between the Home rows (and the Channels rows) jumped to the
geometrically-nearest card, so the row shifted left/right and felt inconsistent.
A focusRestorer on each row now lands focus on the card that row last had, so
the rows stay put as you move between them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
a6d0f9ec6c home: Back opens the sidebar from a list view (#26)
Reaching the sidebar used to mean scrolling all the way left to the first card.
Back from any list view now opens it directly; from the open sidebar on Home a
double Back still exits, and Esc (keyboard/scrcpy) mirrors the same model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
d2b40d5f10 player: title header + scrollable description in info/comments (#23, #24)
The info and comments side panels showed a generic "Info"/"Comments" label,
which read as an empty black bar with no clue which video it belonged to. Both
now show the video's title instead (#24, and the same complaint in #23).

The info panel's description also could not be scrolled with the D-pad: a plain
Text in a LazyColumn takes no focus, so a long description was unreadable past
the first screenful unless it happened to contain links. It is now a focusable,
D-pad-scrollable box; Down at the bottom hands focus to the link list, Left
closes the panel (#23).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
19 changed files with 386 additions and 434 deletions

View file

@ -56,19 +56,13 @@ anything that isn't public.
</p>
**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.
<p align="center">
<img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" />
</p>
Open a channel for its banner, its stats and all its videos — with a Subscribe
button you can reach with the remote.
<p align="center">
<img src="docs/screenshots/channel.png" width="800" alt="A channel page: banner, stats, Subscribe button and the channel's videos" />
</p>
**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

View file

@ -1,7 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only: allow plain HTTP so local mock instances (e.g. the
screenshot/demo server on 10.0.2.2) work without TLS. Release keeps the
default policy. -->
<!--
Debug-only overlay: allow cleartext HTTP so a debug build can talk to the local
mock PeerTube backend (tools/mock) over http://10.0.2.2:PORT for README
screenshots. Merged into the manifest for the debug build type only; the
release build and `main` are unaffected. See tools/mock/README.md.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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 ("<display name>'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 ("<display name>'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<VideoChannelDto> { 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<UserGroup> { 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),

View file

@ -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))
}

View file

@ -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<String?>(null) } // handle
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
// Last channel opened from the Channels screen — refocused on return (#34).
var lastOpenedChannel by rememberSaveable { mutableStateOf<String?>(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<String?>(null) } // shortUUID
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) }
// Thumbnail of the opened card — shown as the player's poster while it loads.
var playingVideoThumb by rememberSaveable { mutableStateOf<String?>(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 },
)
}
}

View file

@ -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),
)
}
}

View file

@ -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,

View file

@ -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<Int, FocusRequester>()
// 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<Int, FocusRequester>()
}
// 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<Color> {
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 572 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 635 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 828 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Before After
Before After

View file

@ -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.

29
tools/mock/README.md Normal file
View file

@ -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.

163
tools/mock/mock_peertube.py Normal file
View file

@ -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()