Compare commits
11 commits
main
...
tools/scre
| Author | SHA1 | Date | |
|---|---|---|---|
| 671d2dd661 | |||
| 35f2633fa5 | |||
| e9d7b473fb | |||
| e8117ab08e | |||
| 069dfe38c4 | |||
| 39d3b12182 | |||
| 84a0527e20 | |||
| 4176edfd69 | |||
| e485caa855 | |||
| a6d0f9ec6c | |||
| d2b40d5f10 |
10
README.md
|
|
@ -56,19 +56,13 @@ anything that isn't public.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Channels.** One row per owner, with video, subscriber and play counts on
|
**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">
|
<p align="center">
|
||||||
<img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" />
|
<img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" />
|
||||||
</p>
|
</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
|
**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
|
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
|
button away. Watching is reported to the server every 20 seconds, so where you
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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
|
Debug-only overlay: allow cleartext HTTP so a debug build can talk to the local
|
||||||
default policy. -->
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application android:usesCleartextTraffic="true" />
|
<application android:usesCleartextTraffic="true" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package net.jeena.jtubetv
|
package net.jeena.jtubetv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
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
|
* 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").
|
* scrolling rows keep a smooth frame rate (user: "the animation has very low fps").
|
||||||
*/
|
*/
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(): ImageLoader =
|
||||||
// A sched trace on the TV (release build) showed the main thread being
|
ImageLoader.Builder(this)
|
||||||
// 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)
|
|
||||||
.bitmapConfig(android.graphics.Bitmap.Config.RGB_565)
|
.bitmapConfig(android.graphics.Bitmap.Config.RGB_565)
|
||||||
.crossfade(false)
|
.crossfade(false)
|
||||||
.dispatcher(Dispatchers.IO.limitedParallelism(2))
|
|
||||||
.decoderDispatcher(Dispatchers.Default.limitedParallelism(2))
|
|
||||||
.transformationDispatcher(Dispatchers.Default.limitedParallelism(1))
|
|
||||||
.build()
|
.build()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -47,8 +47,6 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.withLink
|
import androidx.compose.ui.text.withLink
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.graphics.Color
|
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.KeyEventType
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
|
@ -192,28 +190,64 @@ private fun InfoColumn(
|
||||||
}
|
}
|
||||||
|
|
||||||
val descFocus = remember { FocusRequester() }
|
val descFocus = remember { FocusRequester() }
|
||||||
// The description owns the focus for the whole panel: UP/DOWN scroll it and are
|
val firstLinkFocus = remember { FocusRequester() }
|
||||||
// always consumed, so scrolling can never strand focus somewhere it can't come
|
// Focus the description so the D-pad can scroll it right away (issue #23):
|
||||||
// back from (issue #23 review: "it loses focus and it's impossible to scroll up").
|
// 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) {
|
LaunchedEffect(Unit) {
|
||||||
kotlinx.coroutines.delay(150)
|
kotlinx.coroutines.delay(150)
|
||||||
runCatching { descFocus.requestFocus() }
|
runCatching {
|
||||||
|
if (description.isNotBlank()) descFocus.requestFocus()
|
||||||
|
else firstLinkFocus.requestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (description.isBlank()) {
|
if (description.isNotBlank()) {
|
||||||
SidebarFallback("No description.", onDismiss)
|
|
||||||
} else {
|
|
||||||
DescriptionScroll(
|
DescriptionScroll(
|
||||||
annotated = annotated,
|
annotated = annotated,
|
||||||
focusRequester = descFocus,
|
focusRequester = descFocus,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
// Links stay inline in the running text (issue #23 review) instead of
|
onLeaveBottom = {
|
||||||
// being repeated in a list underneath; OK opens the first one.
|
if (links.isNotEmpty()) {
|
||||||
onActivate = { links.firstOrNull()?.let { open(it) } },
|
runCatching { firstLinkFocus.requestFocus() }.isSuccess
|
||||||
|
} else false
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f, fill = 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,
|
annotated: androidx.compose.ui.text.AnnotatedString,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onActivate: () -> Unit,
|
onLeaveBottom: () -> Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scroll = androidx.compose.foundation.rememberScrollState()
|
val scroll = androidx.compose.foundation.rememberScrollState()
|
||||||
|
|
@ -245,38 +279,21 @@ private fun DescriptionScroll(
|
||||||
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
// One press moves a third of a screenful, so consecutive stops
|
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
|
||||||
// overlap and every line of a long description gets shown (issue
|
when (ke.nativeKeyEvent.keyCode) {
|
||||||
// #23 review: the old 85% step jumped past the middle of the text
|
KeyEvent.KEYCODE_DPAD_DOWN ->
|
||||||
// on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN).
|
if (scroll.value < scroll.maxValue) {
|
||||||
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) }
|
scope.launch { scroll.animateScrollBy(step) }
|
||||||
true
|
true
|
||||||
}
|
} else onLeaveBottom()
|
||||||
isUp -> {
|
KeyEvent.KEYCODE_DPAD_UP ->
|
||||||
|
if (scroll.value > 0) {
|
||||||
scope.launch { scroll.animateScrollBy(-step) }
|
scope.launch { scroll.animateScrollBy(-step) }
|
||||||
true
|
true
|
||||||
}
|
} else true // nothing above the description; stay put
|
||||||
isLeft -> {
|
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
onDismiss(); true
|
onDismiss(); true
|
||||||
}
|
}
|
||||||
isCenter -> {
|
|
||||||
onActivate(); true
|
|
||||||
}
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package net.jeena.jtubetv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.
|
* Channels, SmartTube-style: one row per LOCAL user.
|
||||||
*
|
*
|
||||||
* - Row 1: the logged-in user ("<display name>'s Channels") — only when they own
|
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local
|
||||||
* at least one channel WITH videos; then the other local users by total videos.
|
* users sorted by videos / subscribers / plays (desc).
|
||||||
* - Channels with no videos never render (watching-only accounts), at any level.
|
* - 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.
|
* - Followed-instance channels at the end, grouped per platform.
|
||||||
*
|
*
|
||||||
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
|
* 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,
|
// its shimmer placeholder rows until the (fast-path, few-request) load lands,
|
||||||
// instead of painting a stale snapshot that reshuffled as stats streamed in.
|
// 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) {
|
fun load(force: Boolean = false) {
|
||||||
// Re-entry (destination switch away + back) inside the freshness window:
|
// Re-entry (destination switch away + back) inside the freshness window:
|
||||||
// repaint the in-memory rows and skip the network (issue #13) — rows are
|
// 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.
|
// never torn down on re-entry; an account switch builds a new ViewModel.
|
||||||
if (inFlight) return
|
|
||||||
if (!force && userRows.isNotEmpty() && fresh()) return
|
if (!force && userRows.isNotEmpty() && fresh()) return
|
||||||
inFlight = true
|
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -167,7 +160,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
error = e.message ?: "Could not load channels"
|
error = e.message ?: "Could not load channels"
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
inFlight = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,24 +261,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildRowsInner(host: String?) {
|
private fun rebuildRowsInner(host: String?) {
|
||||||
// Channels without videos don't exist on this screen: plenty of local
|
val locals = allChannels.filter { it.host == host }
|
||||||
// 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 ownNames = userChannels.map { c -> c.name }.toSet()
|
val ownNames = userChannels.map { c -> c.name }.toSet()
|
||||||
val byOwner = locals.groupBy { ownerKey(it) }
|
val byOwner = locals.groupBy { ownerKey(it) }
|
||||||
val groups = byOwner.values.map { row ->
|
val groups = byOwner.values.map { row ->
|
||||||
val sorted = row.sortedWith(
|
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) }
|
compareByDescending<VideoChannelDto> { statOf(it) }
|
||||||
.thenByDescending { playsOf(it) }
|
.thenByDescending { it.followersCount }
|
||||||
.thenByDescending { it.followersCount },
|
.thenByDescending { playsOf(it) },
|
||||||
)
|
)
|
||||||
val own = row.firstOrNull { it.name in ownNames }
|
val own = row.firstOrNull { it.name in ownNames }
|
||||||
UserGroup(
|
UserGroup(
|
||||||
|
|
@ -299,7 +281,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
userRows = groups.sortedWith(
|
userRows = groups.sortedWith(
|
||||||
compareByDescending<UserGroup> { g -> g.channels.any { it.name in ownNames } }
|
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]?.videos ?: 0 } }
|
||||||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.plays ?: 0 } }
|
|
||||||
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
|
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
|
||||||
)
|
)
|
||||||
Log.w(
|
Log.w(
|
||||||
|
|
@ -329,8 +310,6 @@ fun ChannelsScreen(
|
||||||
epoch: Int = 0,
|
epoch: Int = 0,
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
drawerPulse: Int = 0,
|
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),
|
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(epoch) { viewModel.load() }
|
LaunchedEffect(epoch) { viewModel.load() }
|
||||||
|
|
@ -338,8 +317,10 @@ fun ChannelsScreen(
|
||||||
|
|
||||||
when {
|
when {
|
||||||
viewModel.loading && viewModel.userRows.isEmpty() ->
|
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 {
|
androidx.compose.foundation.layout.Column {
|
||||||
|
repeat(2) {
|
||||||
Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
|
Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -349,7 +330,8 @@ fun ChannelsScreen(
|
||||||
.background(Color(0xFF232323)),
|
.background(Color(0xFF232323)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ShimmerRow(size = 1)
|
ShimmerRow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
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
|
// 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").
|
// would steal focus mid-navigation (user: "up jumps to the first row").
|
||||||
val focusHandedOut = remember { mutableStateOf(false) }
|
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()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
viewModel.userRows.forEach { group ->
|
viewModel.userRows.forEach { group ->
|
||||||
item(key = "hdr-" + group.ownerName) {
|
item(key = "hdr-" + group.ownerName) {
|
||||||
|
|
@ -387,22 +361,13 @@ fun ChannelsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item(key = "row-" + group.ownerName) {
|
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(
|
ChannelRow(
|
||||||
group.channels,
|
group.channels,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
focused = !focusHandedOut.value && (
|
focused = !focusHandedOut.value &&
|
||||||
if (focusRowExists) isTargetRow
|
group == viewModel.userRows.firstOrNull(),
|
||||||
else group == viewModel.userRows.firstOrNull()
|
|
||||||
),
|
|
||||||
focusIndex = if (isTargetRow) target else 0,
|
|
||||||
onFocused = { focusHandedOut.value = true },
|
onFocused = { focusHandedOut.value = true },
|
||||||
stats = { viewModel.stats[it.name + "@" + it.host] },
|
stats = { viewModel.stats[it.name + "@" + it.host] },
|
||||||
platformName = { viewModel.platformNames[it.host ?: ""] },
|
platformName = { viewModel.platformNames[it.host ?: ""] },
|
||||||
|
|
@ -457,8 +422,6 @@ private fun ChannelRow(
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
drawerPulse: Int = 0,
|
drawerPulse: Int = 0,
|
||||||
focused: Boolean,
|
focused: Boolean,
|
||||||
/** Which card to hand the initial focus to (issue #34). */
|
|
||||||
focusIndex: Int = 0,
|
|
||||||
onFocused: () -> Unit = {},
|
onFocused: () -> Unit = {},
|
||||||
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||||
platformName: (VideoChannelDto) -> String?,
|
platformName: (VideoChannelDto) -> String?,
|
||||||
|
|
@ -481,39 +444,21 @@ private fun ChannelRow(
|
||||||
val notFullyVisible = target == null ||
|
val notFullyVisible = target == null ||
|
||||||
target.offset < 0 ||
|
target.offset < 0 ||
|
||||||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset
|
target.offset + target.size > rowState.layoutInfo.viewportEndOffset
|
||||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
LaunchedEffect(channels.isNotEmpty(), focusIndex) {
|
LaunchedEffect(channels.isNotEmpty()) {
|
||||||
if (channels.isNotEmpty()) {
|
if (channels.isNotEmpty()) {
|
||||||
val target = focusIndex.coerceIn(0, channels.size - 1)
|
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
// Land on the last-focused card when moving between rows (issue #29). No
|
// Land on the last-focused card when moving between rows (issue #29).
|
||||||
// custom fallback: a requester whose card has scrolled out of composition
|
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
||||||
// crashed with "FocusRequester is not initialized".
|
|
||||||
modifier = Modifier.focusRestorer(),
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
|
@ -586,19 +531,13 @@ fun ChannelCard(
|
||||||
val coverUrl = banner?.let { absoluteBanner(it) }
|
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||||
?: stats?.latestThumbUrl
|
?: stats?.latestThumbUrl
|
||||||
if (coverUrl != null) {
|
if (coverUrl != null) {
|
||||||
// The request must be remembered: ImageRequest has no value equality,
|
ShimmerAsyncImage(
|
||||||
// a fresh request per recomposition reset the shimmer (and retried
|
model = coil.request.ImageRequest.Builder(
|
||||||
// the load) every frame — the fps regression on the TV.
|
androidx.compose.ui.platform.LocalContext.current,
|
||||||
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)
|
|
||||||
.data(coverUrl)
|
.data(coverUrl)
|
||||||
.size(480, 270) // same key as prefetch
|
.size(480, 270) // same key as prefetch
|
||||||
.build()
|
.build(),
|
||||||
}
|
|
||||||
ShimmerAsyncImage(
|
|
||||||
model = coverReq,
|
|
||||||
contentDescription = channel.displayName,
|
contentDescription = channel.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
||||||
|
|
@ -606,18 +545,13 @@ fun ChannelCard(
|
||||||
}
|
}
|
||||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||||
if (avatar != null) {
|
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(
|
ShimmerAsyncImage(
|
||||||
model = avatarReq,
|
model = coil.request.ImageRequest.Builder(
|
||||||
|
androidx.compose.ui.platform.LocalContext.current,
|
||||||
|
)
|
||||||
|
.data(absoluteAvatar(avatar))
|
||||||
|
.size(80, 80)
|
||||||
|
.build(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
base = Color(0xFF333333),
|
base = Color(0xFF333333),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package net.jeena.jtubetv.ui.screens
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
|
@ -453,17 +450,15 @@ fun FocusScrollRow(
|
||||||
val notFullyVisible = target == null ||
|
val notFullyVisible = target == null ||
|
||||||
target.offset < 0 ||
|
target.offset < 0 ||
|
||||||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
||||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
// Jumping between rows lands on the card this row last had focus on, not
|
// Jumping between rows lands on the card this row last had focus on, not
|
||||||
// the geometrically-nearest one (issue #29): the rows stop shifting
|
// the geometrically-nearest one (issue #29): the rows stop shifting
|
||||||
// left/right as you move up and down between them. No custom fallback —
|
// left/right as you move up and down between them.
|
||||||
// handing back a requester whose card has scrolled out of composition
|
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
||||||
// crashed with "FocusRequester is not initialized".
|
|
||||||
modifier = Modifier.focusRestorer(),
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.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))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -87,22 +87,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
||||||
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
||||||
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
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.
|
// NOT saveable: launching the app should never resume a half-watched player session.
|
||||||
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
|
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
|
||||||
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
|
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
|
||||||
var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) }
|
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 restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
|
||||||
var drawerOpen by rememberSaveable { mutableStateOf(false) }
|
var drawerOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
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 localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
|
||||||
val host = video.channel.host
|
val host = video.channel.host
|
||||||
playingVideoHost = if (host != null && host != localHost) host else null
|
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
|
playingVideo = video.shortUUID
|
||||||
}
|
}
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
@ -230,16 +216,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
)
|
)
|
||||||
Destination.Channels -> ChannelsScreen(
|
Destination.Channels -> ChannelsScreen(
|
||||||
onOpenChannel = { handle ->
|
onOpenChannel = { handle -> channelDetails = handle },
|
||||||
channelDetails = handle
|
|
||||||
lastOpenedChannel = handle
|
|
||||||
},
|
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
onOpenDrawer = { drawerOpen = true },
|
onOpenDrawer = { drawerOpen = true },
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
// Back from a channel page re-selects it (issue #34).
|
|
||||||
focusHandle = lastOpenedChannel,
|
|
||||||
viewModel = channelsViewModel,
|
|
||||||
)
|
)
|
||||||
Destination.Search ->
|
Destination.Search ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -293,15 +273,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
shortUuid = playingVideo!!,
|
shortUuid = playingVideo!!,
|
||||||
sourceHost = playingVideoHost,
|
sourceHost = playingVideoHost,
|
||||||
posterUrl = playingVideoThumb,
|
|
||||||
onBack = { playingVideo = null },
|
onBack = { playingVideo = null },
|
||||||
onOpenChannel = { handle, displayName ->
|
onOpenChannel = { handle, displayName ->
|
||||||
playingVideo = null
|
playingVideo = null
|
||||||
channelDetails = handle
|
channelDetails = handle
|
||||||
channelName = displayName
|
channelName = displayName
|
||||||
},
|
},
|
||||||
// A link-opened video has no card thumbnail to pose with.
|
onOpenVideo = { playingVideo = it; playingVideoHost = null },
|
||||||
onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,6 @@ fun PlayerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
/** Set when the video lives on another instance (vidiverse search results). */
|
/** Set when the video lives on another instance (vidiverse search results). */
|
||||||
sourceHost: String? = null,
|
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.
|
// From the info sidebar's local PeerTube video links: switches the player over.
|
||||||
onOpenVideo: (String) -> Unit = {},
|
onOpenVideo: (String) -> Unit = {},
|
||||||
// From the paused/controls overlay's channel icon: (handle, displayName),
|
// From the paused/controls overlay's channel icon: (handle, displayName),
|
||||||
|
|
@ -230,27 +227,15 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is PlayerUiState.Loading -> PlayerLoading(posterUrl)
|
is PlayerUiState.Loading -> PlayerLoading()
|
||||||
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
||||||
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
|
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayerLoading(posterUrl: String? = null) {
|
private fun PlayerLoading() {
|
||||||
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
|
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()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,10 +308,6 @@ private fun PlayerSurface(
|
||||||
// 200 ms: quick flaps cancel the pending change and the overlay stays
|
// 200 ms: quick flaps cancel the pending change and the overlay stays
|
||||||
// composed, so the animation never restarts mid-playback.
|
// composed, so the animation never restarts mid-playback.
|
||||||
var bufferingPending by remember { mutableStateOf(true) }
|
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) {
|
LaunchedEffect(bufferingPending) {
|
||||||
kotlinx.coroutines.delay(200)
|
kotlinx.coroutines.delay(200)
|
||||||
buffering = bufferingPending
|
buffering = bufferingPending
|
||||||
|
|
@ -438,14 +419,7 @@ private fun PlayerSurface(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
// Only real buffering shows the overlay (issue #33): STATE_ENDED used
|
bufferingPending = playbackState != Player.STATE_READY
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onDispose {
|
onDispose {
|
||||||
|
|
@ -599,7 +573,7 @@ private fun PlayerSurface(
|
||||||
// ready to render — metadata resolution alone (the Loading state) is not
|
// ready to render — metadata resolution alone (the Loading state) is not
|
||||||
// enough (issue #8). Fullscreen loading shows the video's own thumbnail
|
// enough (issue #8). Fullscreen loading shows the video's own thumbnail
|
||||||
// dimmed under the circle loader (issue #19) instead of plain black.
|
// dimmed under the circle loader (issue #19) instead of plain black.
|
||||||
if (buffering || !firstFrame) {
|
if (buffering) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -646,9 +620,9 @@ private fun PlayerSurface(
|
||||||
closeSidebar()
|
closeSidebar()
|
||||||
},
|
},
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
// Full height from the very top — the old top padding left a black
|
modifier = Modifier
|
||||||
// strip above the panel (issues #23/#24).
|
.align(Alignment.CenterEnd)
|
||||||
modifier = Modifier.align(Alignment.TopEnd),
|
.padding(top = 48.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,18 +124,11 @@ fun VideoCard(
|
||||||
.background(Color(0xFF222222)),
|
.background(Color(0xFF222222)),
|
||||||
) {
|
) {
|
||||||
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||||
// The request object must be remembered: ImageRequest has no value
|
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||||
// 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
|
// search-index results carry an absolute URL for their own instance
|
||||||
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
||||||
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||||
.build()
|
.build()
|
||||||
}
|
|
||||||
ShimmerAsyncImage(
|
ShimmerAsyncImage(
|
||||||
model = req,
|
model = req,
|
||||||
contentDescription = video.name,
|
contentDescription = video.name,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package net.jeena.jtubetv.ui.screens.list
|
package net.jeena.jtubetv.ui.screens.list
|
||||||
import androidx.compose.material.icons.Icons
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
|
@ -74,42 +73,10 @@ fun VideoListScreen(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val items = viewModel.items
|
val items = viewModel.items
|
||||||
// per-item focus requesters so BACK from the player can refocus the opened card.
|
// 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
|
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
||||||
// every card, and a snapshot map here re-invalidated the reading scopes on
|
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
||||||
// 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>()
|
|
||||||
}
|
}
|
||||||
// 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.
|
// Pull initial focus out of the sidebar rail and into the content grid.
|
||||||
val initialFocus = remember { FocusRequester() }
|
val initialFocus = remember { FocusRequester() }
|
||||||
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
// 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
|
// With a running search the first RESULT gets focus; without one the
|
||||||
// field does (the SearchHeader's own effect) — never both (issue #20).
|
// field does (the SearchHeader's own effect) — never both (issue #20).
|
||||||
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
|
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() }
|
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +99,17 @@ fun VideoListScreen(
|
||||||
lastOpenedIndex = -1
|
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
|
// 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
|
// (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
|
// zero spacing, and the content pane eats 40dp (8 start + 32 end), so the
|
||||||
|
|
@ -187,27 +162,29 @@ fun VideoListScreen(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
|
||||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
// Channel page: the header is PINNED above the grid (issue #27) rather than
|
||||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
// scrolling away as the first grid item — auto-focusing the first video card
|
||||||
state = gridState,
|
// no longer hides the banner, and the Subscribe button on it stays reachable.
|
||||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
if (source is ListSource.Channel) {
|
||||||
) {
|
ChannelHeader(
|
||||||
// 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) {
|
|
||||||
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,
|
channelHeader = viewModel.channelHeader,
|
||||||
videoTotal = viewModel.videoTotal,
|
videoTotal = viewModel.videoTotal,
|
||||||
handle = source.handle,
|
handle = source.handle,
|
||||||
onLeftEdge = onOpenDrawer,
|
onLeftEdge = onOpenDrawer,
|
||||||
)
|
)
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
|
LazyVerticalGrid(
|
||||||
|
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||||
|
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||||
|
state = gridState,
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
// 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) }) {
|
||||||
|
SearchHeader(viewModel, onOpenDrawer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
||||||
|
|
@ -232,16 +209,23 @@ fun VideoListScreen(
|
||||||
// Placeholder cards while the page's first chunk loads (issue #19):
|
// Placeholder cards while the page's first chunk loads (issue #19):
|
||||||
// shaped like the real cards, with the faint shimmer, on every
|
// shaped like the real cards, with the faint shimmer, on every
|
||||||
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
|
// 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) }
|
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
||||||
// A single loading card, not a full fake grid (issue #31).
|
androidx.compose.foundation.layout.Column {
|
||||||
|
repeat(2) {
|
||||||
|
androidx.compose.foundation.layout.Row {
|
||||||
|
repeat(cardsPerRow) {
|
||||||
ShimmerCard(
|
ShimmerCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(6.dp)
|
.padding(6.dp)
|
||||||
.width(rowCardWidth() - 12.dp),
|
.width(rowCardWidth()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
||||||
val video: VideoDto = when (item) {
|
val video: VideoDto = when (item) {
|
||||||
is VideoListItem.HistoryEntry -> item.history.video
|
is VideoListItem.HistoryEntry -> item.history.video
|
||||||
|
|
@ -276,6 +260,7 @@ fun VideoListScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -421,11 +406,8 @@ private fun ScopeChip(
|
||||||
/**
|
/**
|
||||||
* Channel page header (issue #27): the banner is the backdrop of a fixed-height
|
* 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
|
* 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.
|
* 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.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelHeader(
|
private fun ChannelHeader(
|
||||||
|
|
@ -434,10 +416,10 @@ private fun ChannelHeader(
|
||||||
handle: String,
|
handle: String,
|
||||||
onLeftEdge: () -> Unit = {},
|
onLeftEdge: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
if (channelHeader == null) return
|
||||||
val metaColor = Color(0xFFCFCFCF)
|
val metaColor = Color(0xFFCFCFCF)
|
||||||
val banner = channelHeader?.banners?.maxByOrNull { it.width }
|
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||||
val description = channelHeader?.description?.takeIf { it.isNotBlank() }
|
val description = channelHeader.description?.takeIf { it.isNotBlank() }
|
||||||
val accent = remember(handle) { channelAccent(handle) }
|
|
||||||
androidx.compose.foundation.layout.Column(
|
androidx.compose.foundation.layout.Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -445,15 +427,14 @@ private fun ChannelHeader(
|
||||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
|
// Banner area: a fixed height with the identity + stats anchored to its
|
||||||
// description sits in the black strip below it. The whole band is 160 dp
|
// bottom. A long description flows into the black strip BELOW it rather than
|
||||||
// (4 + 116 + 40) — under one card row — so the grid never reflows around it
|
// being pushed up over the banner or clipped (user request).
|
||||||
// and the band stays fully on screen when the first card takes focus (#31).
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(116.dp)
|
.height(178.dp)
|
||||||
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
.background(Color(0xFF1C1C1C)),
|
||||||
) {
|
) {
|
||||||
if (banner != null) {
|
if (banner != null) {
|
||||||
ShimmerAsyncImage(
|
ShimmerAsyncImage(
|
||||||
|
|
@ -477,9 +458,7 @@ private fun ChannelHeader(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
||||||
// did before the redesign — user request). Only once the channel loaded;
|
// did before the redesign — user request).
|
||||||
// the box above keeps the height reserved in the meantime.
|
|
||||||
if (channelHeader != null) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
|
|
@ -497,22 +476,6 @@ private fun ChannelHeader(
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
.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)) {
|
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -568,39 +531,19 @@ private fun ChannelHeader(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
// Description in the black beneath the banner: a long one grows the header
|
||||||
// Description strip in the black beneath the banner. Fixed height so the
|
// downward, fading out of the banner that already darkens into black.
|
||||||
// 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) {
|
if (description != null) {
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
color = Color(0xFFCCCCCC),
|
color = Color(0xFFCCCCCC),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 2,
|
maxLines = 3,
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 878 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 689 KiB After Width: | Height: | Size: 698 KiB |
|
|
@ -15,14 +15,6 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
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"
|
EMU="$SDK/emulator/emulator"
|
||||||
ADB="$SDK/platform-tools/adb"
|
ADB="$SDK/platform-tools/adb"
|
||||||
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
|
@ -84,41 +76,6 @@ if [ -e /dev/kvm ]; then
|
||||||
echo "/dev/kvm found — hardware acceleration on"
|
echo "/dev/kvm found — hardware acceleration on"
|
||||||
fi
|
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 ------------------------------------
|
# --- build combined adb vendor-keys file ------------------------------------
|
||||||
# The TV system image is a user build: adb push to /data/misc/adb/adb_keys is
|
# 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.
|
# blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead.
|
||||||
|
|
|
||||||
29
tools/mock/README.md
Normal 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
|
|
@ -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()
|
||||||