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
|
package net.jeena.jtubetv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||||
import net.jeena.jtubetv.data.auth.TokenStore
|
import net.jeena.jtubetv.data.auth.TokenStore
|
||||||
import net.jeena.jtubetv.data.network.PeerTubeNetwork
|
import net.jeena.jtubetv.data.network.PeerTubeNetwork
|
||||||
|
|
@ -18,9 +20,19 @@ object AppGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class JTubeApp : Application() {
|
class JTubeApp : Application(), coil.ImageLoaderFactory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppGraph.init(this)
|
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) {
|
if (avatar != null) {
|
||||||
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
|
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
|
||||||
?: (AppGraph.tokenStore.baseUrl + avatar.path)
|
?: (AppGraph.tokenStore.baseUrl + avatar.path)
|
||||||
|
|
||||||
|
val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||||
|
.data(url)
|
||||||
|
.size(160)
|
||||||
|
.build()
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = url,
|
model = avReq,
|
||||||
contentDescription = channel.displayName,
|
contentDescription = channel.displayName,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)),
|
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.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
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -146,6 +148,8 @@ fun HomeRowsScreen(
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
|
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(Unit) { viewModel.load() }
|
||||||
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
|
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
|
||||||
val recentItems = viewModel.recent.items
|
val recentItems = viewModel.recent.items
|
||||||
|
|
@ -175,6 +179,8 @@ fun HomeRowsScreen(
|
||||||
},
|
},
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
onLeftAtStart = onOpenDrawer,
|
onLeftAtStart = onOpenDrawer,
|
||||||
|
clickedIndex = continueRestore,
|
||||||
|
restoreToken = restoreToken,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +208,8 @@ fun HomeRowsScreen(
|
||||||
progressOf = { null },
|
progressOf = { null },
|
||||||
onOpenVideo = onOpenVideo,
|
onOpenVideo = onOpenVideo,
|
||||||
onLeftAtStart = onOpenDrawer,
|
onLeftAtStart = onOpenDrawer,
|
||||||
|
clickedIndex = recentRestore,
|
||||||
|
restoreToken = restoreToken,
|
||||||
) { index, video ->
|
) { index, video ->
|
||||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||||
|
|
@ -240,10 +248,26 @@ private fun FocusScrollRow(
|
||||||
progressOf: (String) -> Float?,
|
progressOf: (String) -> Float?,
|
||||||
onOpenVideo: (VideoDto) -> Unit,
|
onOpenVideo: (VideoDto) -> Unit,
|
||||||
onLeftAtStart: () -> 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 = { _, _ -> },
|
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
|
val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>() }
|
||||||
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) }
|
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) {
|
LaunchedEffect(focusedIndex) {
|
||||||
if (focusedIndex > 0) {
|
if (focusedIndex > 0) {
|
||||||
val visible = rowState.layoutInfo.visibleItemsInfo
|
val visible = rowState.layoutInfo.visibleItemsInfo
|
||||||
|
|
@ -261,10 +285,17 @@ private fun FocusScrollRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(videos) { index, video ->
|
itemsIndexed(videos) { index, video ->
|
||||||
|
val fr = remember { FocusRequester() }
|
||||||
|
focusRequesters[index] = fr
|
||||||
VideoCard(
|
VideoCard(
|
||||||
video = video,
|
video = video,
|
||||||
onClick = { onOpenVideo(video) },
|
onClick = {
|
||||||
modifier = Modifier.width(rowCardWidth()),
|
clickedIndex?.intValue = index
|
||||||
|
onOpenVideo(video)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.width(rowCardWidth())
|
||||||
|
.focusRequester(fr),
|
||||||
showPrivacy = false,
|
showPrivacy = false,
|
||||||
compact = true,
|
compact = true,
|
||||||
progress = progressOf(video.shortUUID),
|
progress = progressOf(video.shortUUID),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
package net.jeena.jtubetv.ui.screens
|
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.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -15,17 +25,20 @@ import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
|
@ -33,7 +46,6 @@ import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
|
@ -46,10 +58,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.jeena.jtubetv.AppGraph
|
import net.jeena.jtubetv.AppGraph
|
||||||
|
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||||
import net.jeena.jtubetv.data.api.VideoDetailDto
|
import net.jeena.jtubetv.data.api.VideoDetailDto
|
||||||
import net.jeena.jtubetv.data.auth.TokenStore
|
import net.jeena.jtubetv.data.auth.TokenStore
|
||||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
sealed interface PlayerUiState {
|
sealed interface PlayerUiState {
|
||||||
data object Loading : PlayerUiState
|
data object Loading : PlayerUiState
|
||||||
|
|
@ -58,7 +69,7 @@ sealed interface PlayerUiState {
|
||||||
val playUrl: String,
|
val playUrl: String,
|
||||||
val isHls: Boolean,
|
val isHls: Boolean,
|
||||||
val fileToken: String? = null,
|
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,
|
val resumeAtSeconds: Int? = null,
|
||||||
) : PlayerUiState
|
) : PlayerUiState
|
||||||
data class Error(val message: String) : PlayerUiState
|
data class Error(val message: String) : PlayerUiState
|
||||||
|
|
@ -78,7 +89,7 @@ class PlayerViewModel(
|
||||||
fun load(shortUuid: String) {
|
fun load(shortUuid: String) {
|
||||||
currentShortUuid = shortUuid
|
currentShortUuid = shortUuid
|
||||||
viewModelScope.launch {
|
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()
|
repository.me()
|
||||||
val result = repository.video(shortUuid, userHistory = true)
|
val result = repository.video(shortUuid, userHistory = true)
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|
@ -87,9 +98,8 @@ class PlayerViewModel(
|
||||||
if (play == null) {
|
if (play == null) {
|
||||||
_state.value = PlayerUiState.Error("No playable stream found")
|
_state.value = PlayerUiState.Error("No playable stream found")
|
||||||
} else {
|
} else {
|
||||||
// Non-public media may need a short-lived file token instead of
|
// Non-public media may need a short-lived file token — matters on
|
||||||
// (or in addition to) the Bearer header — matters on instances
|
// instances whose object-storage bucket rejects anonymous requests.
|
||||||
// whose object-storage bucket rejects anonymous requests.
|
|
||||||
var token: String? = null
|
var token: String? = null
|
||||||
if (detail.privacy?.id != 1) {
|
if (detail.privacy?.id != 1) {
|
||||||
token = repository.videoToken(detail.shortUUID).getOrNull()
|
token = repository.videoToken(detail.shortUUID).getOrNull()
|
||||||
|
|
@ -106,14 +116,7 @@ class PlayerViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl
|
/** Sends a final watching report (on exit / periodic), so positions never go stale. */
|
||||||
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). */
|
|
||||||
fun persistExit(shortUuid: String, currentTimeSeconds: Int) {
|
fun persistExit(shortUuid: String, currentTimeSeconds: Int) {
|
||||||
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
|
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +127,12 @@ class PlayerViewModel(
|
||||||
load(uuid)
|
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 {
|
companion object {
|
||||||
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory =
|
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory =
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
|
|
@ -135,17 +144,18 @@ class PlayerViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus
|
* Fullscreen ExoPlayer that accepts the remote (UX report BUG-2): focus lives on a
|
||||||
* lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s,
|
* Compose key handler — OK toggles play/pause, LEFT/RIGHT seek ±10 s, anything else
|
||||||
* other keys show the controller. The player is an overlay; BACK restores the list
|
* shows the controller. No controller flash on start (user request); when paused an
|
||||||
* underneath (BUG-3) and sends a final watching report (BUG-6).
|
* info overlay (channel, title, views, date) appears instead. BACK sends a final
|
||||||
|
* watching report and returns to the list (BUG-3/6).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
shortUuid: String,
|
shortUuid: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: PlayerViewModel = viewModel(
|
viewModel: PlayerViewModel = viewModel(
|
||||||
key = shortUuid,
|
key = "player$shortUuid",
|
||||||
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
|
@ -184,7 +194,7 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: ()
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
androidx.compose.material3.Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
color = Color(0xFF8A8A8A),
|
color = Color(0xFF8A8A8A),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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()
|
Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show()
|
||||||
onBack()
|
onBack()
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusable(),
|
|
||||||
) { Text("Close") }
|
) { Text("Close") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +221,8 @@ internal fun friendlyMediaError(message: String): String =
|
||||||
message.contains("Unable to resolve", ignoreCase = true) ->
|
message.contains("Unable to resolve", ignoreCase = true) ->
|
||||||
"Can't reach the server — check the connection"
|
"Can't reach the server — check the connection"
|
||||||
message.contains("404") -> "This doesn't look like a PeerTube instance"
|
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"
|
message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection"
|
||||||
else -> "Playback error"
|
else -> "Playback error"
|
||||||
}
|
}
|
||||||
|
|
@ -226,24 +236,29 @@ private fun PlayerSurface(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val keyFocus = remember { FocusRequester() }
|
val keyFocus = remember { FocusRequester() }
|
||||||
|
var paused by remember { mutableStateOf(false) }
|
||||||
|
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
|
||||||
|
|
||||||
val player = remember {
|
val player = remember {
|
||||||
// Bearer headers only for instance-served media; never send the user's token to
|
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
|
||||||
// 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 headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) {
|
val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) {
|
||||||
mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}")
|
mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}")
|
||||||
} else emptyMap()
|
} else emptyMap()
|
||||||
val http = DefaultHttpDataSource.Factory()
|
val http = DefaultHttpDataSource.Factory()
|
||||||
.setAllowCrossProtocolRedirects(true)
|
.setAllowCrossProtocolRedirects(true)
|
||||||
.setDefaultRequestProperties(headers)
|
.setDefaultRequestProperties(headers)
|
||||||
val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec ->
|
val resolving = ResolvingDataSource.Factory(
|
||||||
val token = s.fileToken ?: return@Factory dataSpec
|
http,
|
||||||
val uri = dataSpec.uri.buildUpon()
|
object : ResolvingDataSource.Resolver {
|
||||||
.appendQueryParameter("videoFileToken", token)
|
override fun resolveDataSpec(dataSpec: androidx.media3.datasource.DataSpec): androidx.media3.datasource.DataSpec {
|
||||||
.build()
|
val token = s.fileToken ?: return dataSpec
|
||||||
dataSpec.buildUpon().setUri(uri).build()
|
val uri = dataSpec.uri.buildUpon()
|
||||||
}
|
.appendQueryParameter("videoFileToken", token)
|
||||||
|
.build()
|
||||||
|
return dataSpec.buildUpon().setUri(uri).build()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving)
|
val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving)
|
||||||
ExoPlayer.Builder(context)
|
ExoPlayer.Builder(context)
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSource))
|
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSource))
|
||||||
|
|
@ -257,33 +272,44 @@ private fun PlayerSurface(
|
||||||
.setUri(s.playUrl)
|
.setUri(s.playUrl)
|
||||||
.setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4)
|
.setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4)
|
||||||
.build()
|
.build()
|
||||||
player.setMediaItem(item)
|
val resume = s.resumeAtSeconds
|
||||||
s.resumeAtSeconds?.let {
|
if (resume != null) {
|
||||||
player.setMediaItem(item, it * 1000L)
|
player.setMediaItem(item, resume * 1000L)
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
"Resuming at ${it / 60}:${"%02d".format(it % 60)}",
|
"Resuming at " + resume / 60 + ":" + "%02d".format(resume % 60),
|
||||||
Toast.LENGTH_LONG,
|
Toast.LENGTH_LONG,
|
||||||
).show()
|
).show()
|
||||||
} ?: player.setMediaItem(item)
|
} else {
|
||||||
|
player.setMediaItem(item)
|
||||||
|
}
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
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 {
|
onDispose {
|
||||||
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
|
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
|
||||||
player.release()
|
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) {
|
DisposableEffect(player) {
|
||||||
var lastReported = 0L
|
var lastReported = 0L
|
||||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
val handler = Handler(Looper.getMainLooper())
|
||||||
val runnable = object : Runnable {
|
val runnable = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val position = player.currentPosition
|
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())
|
viewModel.persistExit(shortUuid, (position / 1000).toInt())
|
||||||
lastReported = now
|
lastReported = now
|
||||||
}
|
}
|
||||||
|
|
@ -295,52 +321,147 @@ private fun PlayerSurface(
|
||||||
onDispose { handler.removeCallbacks(runnable) }
|
onDispose { handler.removeCallbacks(runnable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val handleKey: (android.view.KeyEvent) -> Boolean = { event ->
|
val handleKey: (KeyEvent) -> Boolean = { event ->
|
||||||
if (event.action == android.view.KeyEvent.ACTION_DOWN) {
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||||
when (event.keyCode) {
|
when (event.keyCode) {
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
|
||||||
-> {
|
|
||||||
player.playWhenReady = !player.playWhenReady
|
player.playWhenReady = !player.playWhenReady
|
||||||
true
|
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))
|
player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0))
|
||||||
true
|
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)
|
player.seekTo(player.currentPosition + 10_000)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false // UP/DOWN etc.: show the controller
|
else -> false
|
||||||
}
|
}
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidView(
|
Box(
|
||||||
factory = { ctx ->
|
|
||||||
PlayerView(ctx).apply {
|
|
||||||
useController = true
|
|
||||||
this.player = player
|
|
||||||
controllerShowTimeoutMs = 4000
|
|
||||||
isFocusable = false
|
|
||||||
descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(keyFocus)
|
.focusRequester(keyFocus)
|
||||||
.focusable()
|
.focusable()
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { ke ->
|
||||||
handleKey(event.nativeKeyEvent)
|
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 {
|
/** Info panel while paused: channel (icon + name), title, views, upload date, position. */
|
||||||
val urlHost = url.substringAfter("://").substringBefore('/')
|
@Composable
|
||||||
val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/')
|
private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Modifier = Modifier) {
|
||||||
return baseHost != null && urlHost == baseHost
|
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)),
|
.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)
|
||||||
|
.data(AppGraph.repository.absoluteUrl(path))
|
||||||
|
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||||
|
.build()
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = AppGraph.repository.absoluteUrl(path),
|
model = req,
|
||||||
contentDescription = video.name,
|
contentDescription = video.name,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
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