diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index 60d5628..ec4c71a 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 26f711a..7a239c8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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)), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 935b14e..dd5b06e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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() } 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), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 7e27467..0297c1a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -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(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) + } } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 5c33ea0..4adffc4 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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 diff --git a/scripts/fps-check.sh b/scripts/fps-check.sh new file mode 100755 index 0000000..6cda1fd --- /dev/null +++ b/scripts/fps-check.sh @@ -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)) +" \ No newline at end of file