Compare commits
30 commits
tools/scre
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b96d03384 | |||
| 22d87d2398 | |||
| 182650414c | |||
| c0a7e94b14 | |||
| e7b15f819e | |||
| 35e0f31a74 | |||
| 0ab19b835c | |||
| 54dfe68726 | |||
| f371a21e17 | |||
| b9419e6a65 | |||
| 45fd1b963e | |||
| 3854c6999a | |||
| 59d93974b5 | |||
| 75a0153141 | |||
| d6fab03c47 | |||
| d5040f8035 | |||
| e6792a98fd | |||
| 0ca17cfd00 | |||
| 9f69cabf79 | |||
| 9ce367b601 | |||
| b78e7187c0 | |||
| bdf9b35ebd | |||
| a08f3b956c | |||
| f2d63fdb1c | |||
| 21d8290f1d | |||
| b2af6fc1a5 | |||
| aea649eab9 | |||
| 8eeffe834d | |||
| 349c1aa447 | |||
| 6307addfb8 |
10
README.md
|
|
@ -56,13 +56,19 @@ 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. Open a channel for its banner, its stats and all its videos — and a
|
every card.
|
||||||
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,10 +1,7 @@
|
||||||
<?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
|
||||||
Debug-only overlay: allow cleartext HTTP so a debug build can talk to the local
|
screenshot/demo server on 10.0.2.2) work without TLS. Release keeps the
|
||||||
mock PeerTube backend (tools/mock) over http://10.0.2.2:PORT for README
|
default policy. -->
|
||||||
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,6 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -33,9 +34,20 @@ 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 {
|
||||||
ImageLoader.Builder(this)
|
// 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)
|
||||||
.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,6 +47,8 @@ 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
|
||||||
|
|
@ -190,64 +192,28 @@ private fun InfoColumn(
|
||||||
}
|
}
|
||||||
|
|
||||||
val descFocus = remember { FocusRequester() }
|
val descFocus = remember { FocusRequester() }
|
||||||
val firstLinkFocus = remember { FocusRequester() }
|
// The description owns the focus for the whole panel: UP/DOWN scroll it and are
|
||||||
// Focus the description so the D-pad can scroll it right away (issue #23):
|
// always consumed, so scrolling can never strand focus somewhere it can't come
|
||||||
// a description with no links used to have nothing focusable, so a long
|
// back from (issue #23 review: "it loses focus and it's impossible to scroll up").
|
||||||
// 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 {
|
runCatching { descFocus.requestFocus() }
|
||||||
if (description.isNotBlank()) descFocus.requestFocus()
|
|
||||||
else firstLinkFocus.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (description.isNotBlank()) {
|
if (description.isBlank()) {
|
||||||
|
SidebarFallback("No description.", onDismiss)
|
||||||
|
} else {
|
||||||
DescriptionScroll(
|
DescriptionScroll(
|
||||||
annotated = annotated,
|
annotated = annotated,
|
||||||
focusRequester = descFocus,
|
focusRequester = descFocus,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onLeaveBottom = {
|
// Links stay inline in the running text (issue #23 review) instead of
|
||||||
if (links.isNotEmpty()) {
|
// being repeated in a list underneath; OK opens the first one.
|
||||||
runCatching { firstLinkFocus.requestFocus() }.isSuccess
|
onActivate = { links.firstOrNull()?.let { open(it) } },
|
||||||
} 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,7 +229,7 @@ private fun DescriptionScroll(
|
||||||
annotated: androidx.compose.ui.text.AnnotatedString,
|
annotated: androidx.compose.ui.text.AnnotatedString,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onLeaveBottom: () -> Boolean,
|
onActivate: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scroll = androidx.compose.foundation.rememberScrollState()
|
val scroll = androidx.compose.foundation.rememberScrollState()
|
||||||
|
|
@ -279,21 +245,38 @@ 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
|
||||||
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
|
// One press moves a third of a screenful, so consecutive stops
|
||||||
when (ke.nativeKeyEvent.keyCode) {
|
// overlap and every line of a long description gets shown (issue
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN ->
|
// #23 review: the old 85% step jumped past the middle of the text
|
||||||
if (scroll.value < scroll.maxValue) {
|
// 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) }
|
scope.launch { scroll.animateScrollBy(step) }
|
||||||
true
|
true
|
||||||
} else onLeaveBottom()
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_UP ->
|
isUp -> {
|
||||||
if (scroll.value > 0) {
|
|
||||||
scope.launch { scroll.animateScrollBy(-step) }
|
scope.launch { scroll.animateScrollBy(-step) }
|
||||||
true
|
true
|
||||||
} else true // nothing above the description; stay put
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
isLeft -> {
|
||||||
onDismiss(); true
|
onDismiss(); true
|
||||||
}
|
}
|
||||||
|
isCenter -> {
|
||||||
|
onActivate(); true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ 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
|
||||||
|
|
@ -73,10 +75,9 @@ 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"), then the other local
|
* - Row 1: the logged-in user ("<display name>'s Channels") — only when they own
|
||||||
* users sorted by videos / subscribers / plays (desc).
|
* at least one channel WITH videos; then the other local users by total videos.
|
||||||
* - Each row: that user's channels, same ordering; channels with no videos simply
|
* - Channels with no videos never render (watching-only accounts), at any level.
|
||||||
* 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
|
||||||
|
|
@ -127,11 +128,17 @@ 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 {
|
||||||
|
|
@ -160,6 +167,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -261,14 +269,24 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildRowsInner(host: String?) {
|
private fun rebuildRowsInner(host: String?) {
|
||||||
val locals = allChannels.filter { it.host == host }
|
// 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 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 { it.followersCount }
|
.thenByDescending { playsOf(it) }
|
||||||
.thenByDescending { playsOf(it) },
|
.thenByDescending { it.followersCount },
|
||||||
)
|
)
|
||||||
val own = row.firstOrNull { it.name in ownNames }
|
val own = row.firstOrNull { it.name in ownNames }
|
||||||
UserGroup(
|
UserGroup(
|
||||||
|
|
@ -281,6 +299,7 @@ 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(
|
||||||
|
|
@ -310,6 +329,8 @@ 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() }
|
||||||
|
|
@ -317,10 +338,8 @@ fun ChannelsScreen(
|
||||||
|
|
||||||
when {
|
when {
|
||||||
viewModel.loading && viewModel.userRows.isEmpty() ->
|
viewModel.loading && viewModel.userRows.isEmpty() ->
|
||||||
// Placeholder rows while the channels load (issue #19): header stub
|
// One row header stub + a single loading card, not a fake grid (#31).
|
||||||
// + 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
|
||||||
|
|
@ -330,8 +349,7 @@ fun ChannelsScreen(
|
||||||
.background(Color(0xFF232323)),
|
.background(Color(0xFF232323)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ShimmerRow()
|
ShimmerRow(size = 1)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|
@ -352,6 +370,14 @@ 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) {
|
||||||
|
|
@ -361,13 +387,22 @@ 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 && (
|
||||||
group == viewModel.userRows.firstOrNull(),
|
if (focusRowExists) isTargetRow
|
||||||
|
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 ?: ""] },
|
||||||
|
|
@ -422,6 +457,8 @@ 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?,
|
||||||
|
|
@ -444,21 +481,39 @@ 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.animateScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (focused) {
|
if (focused) {
|
||||||
LaunchedEffect(channels.isNotEmpty()) {
|
LaunchedEffect(channels.isNotEmpty(), focusIndex) {
|
||||||
if (channels.isNotEmpty()) {
|
if (channels.isNotEmpty()) {
|
||||||
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = rowState,
|
state = rowState,
|
||||||
// Land on the last-focused card when moving between rows (issue #29).
|
// Land on the last-focused card when moving between rows (issue #29). No
|
||||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
// custom fallback: a requester whose card has scrolled out of composition
|
||||||
|
// 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),
|
||||||
) {
|
) {
|
||||||
|
|
@ -531,13 +586,19 @@ fun ChannelCard(
|
||||||
val coverUrl = banner?.let { absoluteBanner(it) }
|
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||||
?: stats?.latestThumbUrl
|
?: stats?.latestThumbUrl
|
||||||
if (coverUrl != null) {
|
if (coverUrl != null) {
|
||||||
ShimmerAsyncImage(
|
// The request must be remembered: ImageRequest has no value equality,
|
||||||
model = coil.request.ImageRequest.Builder(
|
// a fresh request per recomposition reset the shimmer (and retried
|
||||||
androidx.compose.ui.platform.LocalContext.current,
|
// 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)
|
||||||
.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),
|
||||||
|
|
@ -545,13 +606,18 @@ fun ChannelCard(
|
||||||
}
|
}
|
||||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
ShimmerAsyncImage(
|
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
model = coil.request.ImageRequest.Builder(
|
val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
|
||||||
androidx.compose.ui.platform.LocalContext.current,
|
val avatarReq = remember(avatarUrl) {
|
||||||
|
coil.request.ImageRequest.Builder(
|
||||||
|
ctx,
|
||||||
)
|
)
|
||||||
.data(absoluteAvatar(avatar))
|
.data(avatarUrl)
|
||||||
.size(80, 80)
|
.size(80, 80)
|
||||||
.build(),
|
.build()
|
||||||
|
}
|
||||||
|
ShimmerAsyncImage(
|
||||||
|
model = avatarReq,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
base = Color(0xFF333333),
|
base = Color(0xFF333333),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -9,6 +11,7 @@ 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
|
||||||
|
|
@ -450,15 +453,17 @@ 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.animateScrollToItem(focusedIndex)
|
if (notFullyVisible) rowState.easedScrollToItem(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.
|
// left/right as you move up and down between them. No custom fallback —
|
||||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
// handing back a requester whose card has scrolled out of composition
|
||||||
|
// 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),
|
||||||
) {
|
) {
|
||||||
|
|
@ -493,3 +498,19 @@ 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,10 +87,22 @@ 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
|
||||||
|
|
@ -175,6 +187,8 @@ 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()) {
|
||||||
|
|
@ -216,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
drawerPulse = drawerPulse,
|
drawerPulse = drawerPulse,
|
||||||
)
|
)
|
||||||
Destination.Channels -> ChannelsScreen(
|
Destination.Channels -> ChannelsScreen(
|
||||||
onOpenChannel = { handle -> channelDetails = handle },
|
onOpenChannel = { 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(
|
||||||
|
|
@ -273,13 +293,15 @@ 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
|
||||||
},
|
},
|
||||||
onOpenVideo = { playingVideo = it; playingVideoHost = null },
|
// A link-opened video has no card thumbnail to pose with.
|
||||||
|
onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,9 @@ 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),
|
||||||
|
|
@ -227,15 +230,27 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is PlayerUiState.Loading -> PlayerLoading()
|
is PlayerUiState.Loading -> PlayerLoading(posterUrl)
|
||||||
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() {
|
private fun PlayerLoading(posterUrl: String? = null) {
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +323,10 @@ 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
|
||||||
|
|
@ -419,7 +438,14 @@ private fun PlayerSurface(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
bufferingPending = playbackState != Player.STATE_READY
|
// 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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onDispose {
|
onDispose {
|
||||||
|
|
@ -573,7 +599,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) {
|
if (buffering || !firstFrame) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -620,9 +646,9 @@ private fun PlayerSurface(
|
||||||
closeSidebar()
|
closeSidebar()
|
||||||
},
|
},
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
modifier = Modifier
|
// Full height from the very top — the old top padding left a black
|
||||||
.align(Alignment.CenterEnd)
|
// strip above the panel (issues #23/#24).
|
||||||
.padding(top = 48.dp),
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,11 +124,18 @@ fun VideoCard(
|
||||||
.background(Color(0xFF222222)),
|
.background(Color(0xFF222222)),
|
||||||
) {
|
) {
|
||||||
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||||
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
// 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
|
// 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,5 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -73,10 +74,42 @@ 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.
|
||||||
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
// A plain HashMap, like the rows: this is/was written during composition of
|
||||||
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
// every card, and a snapshot map here re-invalidated the reading scopes on
|
||||||
|
// each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5).
|
||||||
|
// Keyed only on [epoch]: on items.size the map was dropped wholesale, and
|
||||||
|
// already-composed cards didn't re-populate it until their next recompose.
|
||||||
|
val requesters = androidx.compose.runtime.remember(epoch) {
|
||||||
|
HashMap<Int, FocusRequester>()
|
||||||
}
|
}
|
||||||
|
// 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).
|
||||||
|
|
@ -85,6 +118,9 @@ 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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,17 +135,6 @@ 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
|
||||||
|
|
@ -162,29 +187,27 @@ fun VideoListScreen(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
|
LazyVerticalGrid(
|
||||||
// Channel page: the header is PINNED above the grid (issue #27) rather than
|
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||||
// scrolling away as the first grid item — auto-focusing the first video card
|
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||||
// no longer hides the banner, and the Subscribe button on it stays reachable.
|
state = gridState,
|
||||||
if (source is ListSource.Channel) {
|
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||||
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) {
|
||||||
|
|
@ -209,23 +232,16 @@ 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", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
item(key = "initial-placeholders") {
|
||||||
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
||||||
androidx.compose.foundation.layout.Column {
|
// A single loading card, not a full fake grid (issue #31).
|
||||||
repeat(2) {
|
|
||||||
androidx.compose.foundation.layout.Row {
|
|
||||||
repeat(cardsPerRow) {
|
|
||||||
ShimmerCard(
|
ShimmerCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(6.dp)
|
.padding(6.dp)
|
||||||
.width(rowCardWidth()),
|
.width(rowCardWidth() - 12.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|
@ -261,7 +277,6 @@ fun VideoListScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
|
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
|
||||||
|
|
@ -406,8 +421,11 @@ 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's pinned above the grid so
|
* and a reachable Subscribe button laid over it. It scrolls with the video grid.
|
||||||
* 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(
|
||||||
|
|
@ -416,10 +434,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()
|
||||||
|
|
@ -427,14 +445,15 @@ 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: a fixed height with the identity + stats anchored to its
|
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
|
||||||
// bottom. A long description flows into the black strip BELOW it rather than
|
// description sits in the black strip below it. The whole band is 160 dp
|
||||||
// being pushed up over the banner or clipped (user request).
|
// (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).
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(178.dp)
|
.height(116.dp)
|
||||||
.background(Color(0xFF1C1C1C)),
|
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
||||||
) {
|
) {
|
||||||
if (banner != null) {
|
if (banner != null) {
|
||||||
ShimmerAsyncImage(
|
ShimmerAsyncImage(
|
||||||
|
|
@ -458,7 +477,9 @@ 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).
|
// did before the redesign — user request). Only once the channel loaded;
|
||||||
|
// 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)
|
||||||
|
|
@ -476,6 +497,22 @@ 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(
|
||||||
|
|
@ -531,20 +568,40 @@ private fun ChannelHeader(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Description in the black beneath the banner: a long one grows the header
|
}
|
||||||
// downward, fading out of the banner that already darkens into black.
|
// 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) {
|
if (description != null) {
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
color = Color(0xFFCCCCCC),
|
color = Color(0xFFCCCCCC),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
maxLines = 3,
|
maxLines = 2,
|
||||||
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
|
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
|
||||||
|
|
|
||||||
BIN
docs/screenshots/channel.png
Normal file
|
After Width: | Height: | Size: 878 KiB |
|
Before Width: | Height: | Size: 572 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 635 KiB After Width: | Height: | Size: 908 KiB |
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 689 KiB |
|
|
@ -15,6 +15,14 @@
|
||||||
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"
|
||||||
|
|
@ -76,6 +84,41 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
#!/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()
|
|
||||||