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:
parent
694467b038
commit
51077b8352
6 changed files with 296 additions and 76 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
dataSpec.buildUpon().setUri(uri).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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
47
scripts/fps-check.sh
Executable 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))
|
||||
"
|
||||
Loading…
Add table
Add a link
Reference in a new issue