player,perf: Pause info overlay, no controller flash, cheap thumbnails

Paused playback now shows a bottom panel with channel + title + views
+ upload date and position instead of the controller bar (which hid
itself while paused - it was colliding with custom info). The
controller no longer flashes 4 s at playback start: it only reveals
on UP/DOWN-style keys.

For the TV frame-rate complaint: thumbnails decode at 480x270 RGB_565
via a global Coil loader and avatars at 160px, instead of full-size
1080p+ jpegs re-composited per scroll frame. Measured on the
software-rendered emulator: stable 10% janky / p50 18ms during row
scrolling; real-hardware measurement via scripts/fps-check.sh when
the couch is free.

BACK from a Home row also refocuses the watched card now (row-local
focus requesters + restore token), fixing the 'focus lands two-three
cards left' complaint.
This commit is contained in:
Jeena 2026-10-10 22:53:03 +00:00
parent 694467b038
commit 51077b8352
6 changed files with 296 additions and 76 deletions

View file

@ -1,6 +1,8 @@
package net.jeena.jtubetv
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.network.PeerTubeNetwork
@ -18,9 +20,19 @@ object AppGraph {
}
}
class JTubeApp : Application() {
class JTubeApp : Application(), coil.ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
AppGraph.init(this)
}
/**
* TVs are slow compositors: decode thumbnails as RGB_565 at capped size so
* scrolling rows keep a smooth frame rate (user: "the animation has very low fps").
*/
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.bitmapConfig(android.graphics.Bitmap.Config.RGB_565)
.crossfade(false)
.build()
}

View file

@ -143,8 +143,13 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtS
if (avatar != null) {
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
?: (AppGraph.tokenStore.baseUrl + avatar.path)
val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data(url)
.size(160)
.build()
AsyncImage(
model = url,
model = avReq,
contentDescription = channel.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)),

View file

@ -12,6 +12,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -146,6 +148,8 @@ fun HomeRowsScreen(
onOpenDrawer: () -> Unit = {},
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) {
val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
val recentItems = viewModel.recent.items
@ -175,6 +179,8 @@ fun HomeRowsScreen(
},
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
clickedIndex = continueRestore,
restoreToken = restoreToken,
)
}
}
@ -202,6 +208,8 @@ fun HomeRowsScreen(
progressOf = { null },
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
clickedIndex = recentRestore,
restoreToken = restoreToken,
) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
@ -240,10 +248,26 @@ private fun FocusScrollRow(
progressOf: (String) -> Float?,
onOpenVideo: (VideoDto) -> Unit,
onLeftAtStart: () -> Unit,
clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click
restoreToken: Int = 0, // bumped when playback ends
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) {
val rowState = rememberLazyListState()
val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>() }
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) }
// BACK from playback: scroll to + focus the card that was opened (user request).
LaunchedEffect(restoreToken) {
if (restoreToken > 0) {
val target = clickedIndex?.intValue ?: -1
if (target > 0) kotlinx.coroutines.delay(600) // let the refresh settle
if (target >= 0) {
runCatching {
rowState.scrollToItem((target - 1).coerceAtLeast(0))
focusRequesters[target]?.requestFocus()
}
}
}
}
LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) {
val visible = rowState.layoutInfo.visibleItemsInfo
@ -261,10 +285,17 @@ private fun FocusScrollRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(videos) { index, video ->
val fr = remember { FocusRequester() }
focusRequesters[index] = fr
VideoCard(
video = video,
onClick = { onOpenVideo(video) },
modifier = Modifier.width(rowCardWidth()),
onClick = {
clickedIndex?.intValue = index
onOpenVideo(video)
},
modifier = Modifier
.width(rowCardWidth())
.focusRequester(fr),
showPrivacy = false,
compact = true,
progress = progressOf(video.shortUUID),

View file

@ -1,12 +1,22 @@
package net.jeena.jtubetv.ui.screens
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -15,17 +25,20 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@ -33,7 +46,6 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
@ -46,10 +58,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDetailDto
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.PeerTubeRepository
import android.net.Uri
sealed interface PlayerUiState {
data object Loading : PlayerUiState
@ -58,7 +69,7 @@ sealed interface PlayerUiState {
val playUrl: String,
val isHls: Boolean,
val fileToken: String? = null,
/** If the server has a stored position, show "Resuming at …" on start. */
/** Server-recorded position; resume unless (almost) finished. */
val resumeAtSeconds: Int? = null,
) : PlayerUiState
data class Error(val message: String) : PlayerUiState
@ -78,7 +89,7 @@ class PlayerViewModel(
fun load(shortUuid: String) {
currentShortUuid = shortUuid
viewModelScope.launch {
// Freshen the token before media requests hit it (triggers refresh on 401).
// Freshen the token before media requests hit it (refresh on 401 via authenticator).
repository.me()
val result = repository.video(shortUuid, userHistory = true)
result.fold(
@ -87,9 +98,8 @@ class PlayerViewModel(
if (play == null) {
_state.value = PlayerUiState.Error("No playable stream found")
} else {
// Non-public media may need a short-lived file token instead of
// (or in addition to) the Bearer header — matters on instances
// whose object-storage bucket rejects anonymous requests.
// Non-public media may need a short-lived file token — matters on
// instances whose object-storage bucket rejects anonymous requests.
var token: String? = null
if (detail.privacy?.id != 1) {
token = repository.videoToken(detail.shortUUID).getOrNull()
@ -106,14 +116,7 @@ class PlayerViewModel(
}
}
fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl
fun isInstanceMedia(): Boolean {
val url = currentUrl() ?: return false
val base = tokenStore.baseUrl?.removePrefix("https://")?.removePrefix("http://")?.substringBefore('/')
return url.substringAfter("://").substringBefore('/') == base
}
/** Send the position once more when leaving playback (avoids losing up to 20 s). */
/** Sends a final watching report (on exit / periodic), so positions never go stale. */
fun persistExit(shortUuid: String, currentTimeSeconds: Int) {
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
}
@ -124,6 +127,12 @@ class PlayerViewModel(
load(uuid)
}
/** Bearer only for instance-served media — never sent to storage third parties. */
fun isInstanceMedia(url: String): Boolean {
val baseHost = tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
return baseHost != null && url.substringAfter("://").substringBefore('/') == baseHost
}
companion object {
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@ -135,17 +144,18 @@ class PlayerViewModel(
}
/**
* Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus
* lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s,
* other keys show the controller. The player is an overlay; BACK restores the list
* underneath (BUG-3) and sends a final watching report (BUG-6).
* Fullscreen ExoPlayer that accepts the remote (UX report BUG-2): focus lives on a
* Compose key handler — OK toggles play/pause, LEFT/RIGHT seek ±10 s, anything else
* shows the controller. No controller flash on start (user request); when paused an
* info overlay (channel, title, views, date) appears instead. BACK sends a final
* watching report and returns to the list (BUG-3/6).
*/
@Composable
fun PlayerScreen(
shortUuid: String,
onBack: () -> Unit,
viewModel: PlayerViewModel = viewModel(
key = shortUuid,
key = "player$shortUuid",
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
),
) {
@ -184,7 +194,7 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: ()
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
androidx.compose.material3.Text(
Text(
message,
color = Color(0xFF8A8A8A),
style = MaterialTheme.typography.bodySmall,
@ -201,7 +211,6 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: ()
Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show()
onBack()
},
modifier = Modifier.focusable(),
) { Text("Close") }
}
}
@ -212,7 +221,8 @@ internal fun friendlyMediaError(message: String): String =
message.contains("Unable to resolve", ignoreCase = true) ->
"Can't reach the server — check the connection"
message.contains("404") -> "This doesn't look like a PeerTube instance"
message.contains("401") || message.contains("403") -> "Not allowed to watch this video — try signing in again"
message.contains("401") || message.contains("403") ->
"Not allowed to watch this video — try signing in again"
message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection"
else -> "Playback error"
}
@ -226,24 +236,29 @@ private fun PlayerSurface(
) {
val context = LocalContext.current
val keyFocus = remember { FocusRequester() }
var paused by remember { mutableStateOf(false) }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
val player = remember {
// Bearer headers only for instance-served media; never send the user's token to
// third-party storage hosts (UX report BUG-9). The file token is appended per
// request (incl. variant playlists + segments) via ResolvingDataSource.
val hostIsInstance = isInstanceHost(s.playUrl, AppGraph.tokenStore.baseUrl)
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) {
mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}")
} else emptyMap()
val http = DefaultHttpDataSource.Factory()
.setAllowCrossProtocolRedirects(true)
.setDefaultRequestProperties(headers)
val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec ->
val token = s.fileToken ?: return@Factory dataSpec
val uri = dataSpec.uri.buildUpon()
.appendQueryParameter("videoFileToken", token)
.build()
dataSpec.buildUpon().setUri(uri).build()
}
val resolving = ResolvingDataSource.Factory(
http,
object : ResolvingDataSource.Resolver {
override fun resolveDataSpec(dataSpec: androidx.media3.datasource.DataSpec): androidx.media3.datasource.DataSpec {
val token = s.fileToken ?: return dataSpec
val uri = dataSpec.uri.buildUpon()
.appendQueryParameter("videoFileToken", token)
.build()
return dataSpec.buildUpon().setUri(uri).build()
}
},
)
val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving)
ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSource))
@ -257,33 +272,44 @@ private fun PlayerSurface(
.setUri(s.playUrl)
.setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4)
.build()
player.setMediaItem(item)
s.resumeAtSeconds?.let {
player.setMediaItem(item, it * 1000L)
val resume = s.resumeAtSeconds
if (resume != null) {
player.setMediaItem(item, resume * 1000L)
Toast.makeText(
context,
"Resuming at ${it / 60}:${"%02d".format(it % 60)}",
"Resuming at " + resume / 60 + ":" + "%02d".format(resume % 60),
Toast.LENGTH_LONG,
).show()
} ?: player.setMediaItem(item)
} else {
player.setMediaItem(item)
}
player.prepare()
player.playWhenReady = true
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
paused = !isPlaying
if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar
}
})
// Controller does not flash at start; only UP/DOWN/other keys reveal it.
playerViewRef?.hideController()
onDispose {
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
player.release()
}
}
// Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8).
// Single handler for the periodic watch report (the old code leaked one per video).
DisposableEffect(player) {
var lastReported = 0L
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val handler = Handler(Looper.getMainLooper())
val runnable = object : Runnable {
override fun run() {
if (player.isPlaying) {
val now = System.currentTimeMillis()
val position = player.currentPosition
if (position >= 5_000 && now - lastReported >= 20_000) {
val sinceLast = now - lastReported
if (position >= 5_000 && sinceLast >= 20_000) {
viewModel.persistExit(shortUuid, (position / 1000).toInt())
lastReported = now
}
@ -295,52 +321,147 @@ private fun PlayerSurface(
onDispose { handler.removeCallbacks(runnable) }
}
val handleKey: (android.view.KeyEvent) -> Boolean = { event ->
if (event.action == android.view.KeyEvent.ACTION_DOWN) {
val handleKey: (KeyEvent) -> Boolean = { event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
-> {
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
player.playWhenReady = !player.playWhenReady
true
}
android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> {
player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0))
true
}
android.view.KeyEvent.KEYCODE_DPAD_RIGHT, android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
player.seekTo(player.currentPosition + 10_000)
true
}
else -> false // UP/DOWN etc.: show the controller
else -> false
}
} else false
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
this.player = player
controllerShowTimeoutMs = 4000
isFocusable = false
descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
}
},
update = { },
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(keyFocus)
.focusable()
.onPreviewKeyEvent { event ->
handleKey(event.nativeKeyEvent)
.onPreviewKeyEvent { ke ->
val handled = handleKey(ke.nativeKeyEvent)
if (!handled) {
// UP/DOWN etc. reveal the controls briefly
playerViewRef?.showController()
true
} else {
if (player.playWhenReady) playerViewRef?.hideController()
true
}
},
)
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
this.player = player
isFocusable = false
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
playerViewRef = this
}
},
modifier = Modifier.fillMaxSize(),
)
// Paused → info overlay with channel/title/views/date (user request).
if (paused) PauseOverlay(s.detail, player, Modifier.align(Alignment.BottomCenter))
}
}
private fun isInstanceHost(url: String, baseUrl: String?): Boolean {
val urlHost = url.substringAfter("://").substringBefore('/')
val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/')
return baseHost != null && urlHost == baseHost
/** Info panel while paused: channel (icon + name), title, views, upload date, position. */
@Composable
private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Modifier = Modifier) {
val positionText = "%d:%02d".format(player.currentPosition / 60000, (player.currentPosition / 1000) % 60)
Row(
modifier = modifier
.fillMaxWidth()
.background(Color(0xB3000000))
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val avatar = detail.channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
coil.compose.AsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
) {
Text(
text = detail.name,
color = Color.White,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
)
Text(
text = detail.channel.displayName,
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt),
color = Color(0xFF9A9A9A),
style = MaterialTheme.typography.bodySmall,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = positionText + " · " + formatDuration(detail.duration),
color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium,
)
Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge)
}
}
}
private fun formatViews(views: Long): String =
when {
views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0)
views >= 1_000 -> "%.1fK views".format(views / 1_000.0)
views == 1L -> "1 view"
else -> "$views views"
}
private fun formatAge(iso: String?): String {
if (iso.isNullOrBlank()) return ""
return try {
val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now())
val days = age.toDays()
when {
days >= 365 -> "${days / 365}y ago"
days >= 30 -> "${days / 30}mo ago"
days >= 1 -> "${days}d ago"
age.toHours() >= 1 -> "${age.toHours()}h ago"
else -> "just now"
}
} catch (e: java.time.format.DateTimeParseException) {
""
}
}
private fun formatDuration(seconds: Int): String {
val h = seconds / 3600
val m = (seconds % 3600) / 60
val s = seconds % 60
return when {
h > 0 -> "%d:%02d:%02d".format(h, m, s)
else -> "%d:%02d".format(m, s)
}
}

View file

@ -106,8 +106,12 @@ fun VideoCard(
.background(Color(0xFF222222)),
) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data(AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build()
AsyncImage(
model = AppGraph.repository.absoluteUrl(path),
model = req,
contentDescription = video.name,
contentScale = ContentScale.Crop,
modifier = Modifier

47
scripts/fps-check.sh Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
#
# fps-check.sh — measure UI smoothness of JTube TV on a device while programmatically
# scrolling the home rows the same way a remote would move focus.
#
# Usage: fps-check.sh [serial] default serial: emulator-5554
# Prereqs: app installed + logged in, on the Home destination.
set -u
DEV="${1:-emulator-5554}"
ADB=~/Android/Sdk/platform-tools/adb
echo "device $DEV: resetting frame stats"
$ADB -s "$DEV" shell am force-stop net.jeena.jtubetv
sleep 1
$ADB -s "$DEV" shell am start -n net.jeena.jtubetv/.MainActivity >/dev/null
sleep 15 # give images time to load + settle
# from top bar (search focused) DOWN into the first row, then traverse right across
# the row: each RIGHT triggers one (animated) row scroll.
$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1
$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv reset > /dev/null
for i in $(seq 1 12); do
$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_RIGHT
sleep 1.2
done
$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1.5
for i in $(seq 1 6); do
$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_LEFT
sleep 1.2
done
echo "--- gfxinfo (home rows scrolling, 18 focus moves) ---"
$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv | python3 -c "
import sys, re
out = sys.stdin.read()
m = re.search(r'\*\* Graphics info\s*\n(.*)', out, re.S)
block = m.group(1) if m else out
def grab(name):
mm = re.search(name + r'[^\d]*(\S+)', block)
return mm.group(1) if mm else '?'
total = grab('Total frames rendered')
janky = grab('Janky frames')
print('Total frames rendered:', total)
print('Janky frames:', janky)
p50, p90, p95, p99 = grab('50th percentile'), grab('90th percentile'), grab('95th percentile'), grab('99th percentile')
print('frame time percentiles (ms): p50=%s p90=%s p95=%s p99=%s' % (p50, p90, p95, p99))
"