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.
-Open a channel for its banner, its stats and all its videos — with a Subscribe
-button you can reach with the remote.
-
-
-
-
-
**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()