player,ui: Fix the remote-blocking bugs from the Claude UX run

Claude Code drove the emulator through the TESTS.md checklist
(reports/ux-2026-10-10.md) and found the release blockers. Fixes:
player ignores the D-pad after controller auto-hide (BUG-2, focus +
key forwarding: CENTER play/pause, LEFT/RIGHT seek, others show
controls), Channels 404 for email logins (BUG-1, authenticate the
users/me call at login), focus + history lost after BACK (BUG-3/7,
restore token re-focuses the opened card and History/Continue
watching refresh), Home rows clipped below 1080px (BUG-4, scrollable
LazyColumn), text-field D-pad focus traps (BUG-5, IME actions + key
escapes), Continue watching started at 0:00 and overwrote server
position (BUG-6, resume from userHistory + persist on exit), leaked
report timer (BUG-8), token sent to third-party media hosts (BUG-9,
ResolvingDataSource appends videoFileToken per request, Bearer only
for instance host), player error Retry (BUG-10), raw error strings
(BUG-13), y-ago format bug (BUG-14), sign-out double press (BUG-15),
T18 expectation corrected (server omits private videos from history).

Verified on emuluator: CENTER pauses + shows controller; BACK returns
to refreshed Continue watching with progress bars.
This commit is contained in:
Jeena 2026-10-10 12:23:11 +00:00
parent 43056065e1
commit 9d89c1c060
11 changed files with 411 additions and 117 deletions

View file

@ -67,11 +67,28 @@ class PeerTubeRepository(
expiresAtMillis = System.currentTimeMillis() +
(token.expiresIn ?: 86400L) * 1000L,
)
// Logins accept emails; resolve the real account name for /accounts/{handle}.
// Login accepts emails; resolve the real account name for /accounts/{handle}.
// The request MUST carry the fresh token (UX report BUG-1: unauthenticated
// /users/me returns 401 and the fallback breaks Channels for email logins).
try {
store.accountName = api.me().username
val authed = plainOkHttp().newBuilder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer ${token.accessToken}")
.build()
)
}
.build()
val meApi = Retrofit.Builder()
.baseUrl(apiUrlOf(base) + "/")
.client(authed)
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
.build()
.create(PeerTubeApi::class.java)
store.accountName = meApi.me().username
} catch (_: Exception) {
store.accountName = username // best effort fallback
store.accountName = username.substringBefore('@') // best effort fallback
}
}

View file

@ -39,12 +39,23 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) {
"Invalid credentials"
} else {
"Could not reach instance: " + (result.exceptionOrNull()?.message ?: "unknown error")
friendlyLoginError(result.exceptionOrNull())
}
}
}
}
private fun friendlyLoginError(e: Throwable?): String {
val msg = e?.message ?: ""
return when {
msg.contains("404") -> "That URL doesn't look like a PeerTube instance"
msg.contains("Unable to resolve", ignoreCase = true) ->
"Can't reach the server — check the URL and your connection"
msg.contains("401") || msg.contains("403") -> "Your account can't access this instance"
else -> "Could not reach the instance: " + msg.ifBlank { "unknown error" }
}
}
/** Clears the stored session; the user returns to the setup screen. */
fun logout() {
repository.signOut()

View file

@ -54,6 +54,27 @@ abstract class PagedListViewModel<T> : ViewModel() {
}
}
/**
* Silently replace items with a fresh first page (UX report BUG-7): keeps the
* old list visible while loading, no spinner flash.
*/
fun refresh() {
if (inFlight) return
inFlight = true
viewModelScope.launch {
try {
val result = fetch(0, pageSize)
result.onSuccess { fresh ->
exhausted = fresh.size < pageSize
items = fresh
initialError = null
}
} finally {
inFlight = false
}
}
}
fun retryInitial() {
items = emptyList()
exhausted = false

View file

@ -1,15 +1,14 @@
package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
@ -18,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -36,10 +34,9 @@ import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.VideoListViewModel
import net.jeena.jtubetv.ui.screens.list.VideoCard
private const val ROW_CARD_WIDTH = 296
private val ROW_CARD_WIDTH = 296.dp
/** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */
data class ContinueItem(val video: VideoDto, val progress: Float?)
@ -54,18 +51,31 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
val recent = RecentVideosViewModel(repository)
/** History's newest entries + their resume positions from video detail */
/** History's newest entries + resume positions from the video detail endpoint. */
fun load() {
if (loaded) return
loaded = true
fetchContinue()
}
/** Re-fetch after playback changed server-side positions (UX report BUG-7). */
fun refresh() {
continueItems = emptyList()
loaded = true
fetchContinue()
recent.refresh()
}
private fun fetchContinue() {
viewModelScope.launch {
continueLoading = true
val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() }
if (history == null) {
continueLoading = false
return@launch
}
// resume info needs one detail call with ?userHistory=true per entry;
// keep it small - only the first six videos.
// Resume info needs one detail call with ?userHistory=true per entry;
// keep it small — only the first six videos.
val items = history.take(6).map { entry ->
async {
val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull()
@ -106,69 +116,35 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL
}
/**
* SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress
* bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally.
* SmartTube-style home: stacked horizontal rows ("Continue watching" with progress
* bars, "New videos" paged) inside a scrollable LazyColumn (UX report BUG-4: the
* plain Column clipped the second row below 1080 px).
*/
@Composable
fun HomeRowsScreen(
onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0,
viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory),
) {
LaunchedEffect(Unit) { viewModel.load() }
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
val recentItems = viewModel.recent.items
Column(modifier = Modifier.fillMaxSize()) {
if (viewModel.continueItems.isNotEmpty()) {
VideoRow(
header = "Continue watching",
videos = viewModel.continueItems.map { it.video },
progressOf = { shortUUID ->
viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress
},
onOpenVideo = onOpenVideo,
// already limited to 6; no paging
onNearEnd = { },
)
}
VideoRow(
header = "New videos",
videos = recentItems,
progressOf = { null },
onOpenVideo = onOpenVideo,
onNearEnd = { viewModel.recent.loadMore() },
)
}
}
@Composable
private fun VideoRow(
header: String,
videos: List<VideoDto>,
progressOf: (String) -> Float?,
onOpenVideo: (VideoDto) -> Unit,
onNearEnd: () -> Unit,
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
TvText(
text = header,
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Text(
text = "Continue watching",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
)
if (videos.isEmpty()) {
LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows
Box(
Modifier
.fillMaxWidth()
.height(64.dp),
contentAlignment = Alignment.CenterStart,
) {
TvText(
text = "Loading…",
}
item {
if (viewModel.continueItems.isEmpty()) {
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text(
text = if (viewModel.continueLoading) "Loading…" else "Watch something first",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
modifier = Modifier.padding(start = 16.dp),
)
}
} else {
@ -176,16 +152,50 @@ private fun VideoRow(
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(videos) { index, video ->
itemsIndexed(viewModel.continueItems) { _, item ->
VideoCard(
video = item.video,
onClick = { onOpenVideo(item.video) },
modifier = Modifier.width(ROW_CARD_WIDTH),
showPrivacy = false,
progress = item.progress,
)
}
}
}
}
item {
Text(
text = "New videos",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
Row(Modifier.padding(start = 16.dp)) {
Text(
text = "Loading…",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
)
}
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(recentItems) { index, video ->
VideoCard(
video = video,
onClick = { onOpenVideo(video) },
modifier = Modifier.width(ROW_CARD_WIDTH.dp),
modifier = Modifier.width(ROW_CARD_WIDTH),
showPrivacy = false,
progress = progressOf(video.shortUUID),
)
if (index >= videos.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { onNearEnd() }
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
}
}
}

View file

@ -14,7 +14,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.PersonalVideo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -47,6 +50,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
// After BACK from the player: bump the restore token so lists re-focus the opened
// card and re-fetch positions (UX report BUG-3 + BUG-7).
LaunchedEffect(playingVideo) {
if (playingVideo == null && restoreToken >= 1) restoreToken++
}
// Channel drilldown hides the sidebar; remote BACK must return to the channels list.
BackHandler(enabled = playingVideo == null && channelDetails != null) {
@ -64,6 +74,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
)
}
} else {
@ -90,9 +101,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
content = { Text(dest.title) },
)
}
val signOutContext = androidx.compose.ui.platform.LocalContext.current
var lastSignOutTap by remember { mutableStateOf(0L) }
NavigationDrawerItem(
selected = false,
onClick = { viewModel.logout() },
onClick = {
val now = System.currentTimeMillis()
if (now - lastSignOutTap < 3000) {
viewModel.logout()
} else {
lastSignOutTap = now
android.widget.Toast.makeText(
signOutContext,
"Press again to sign out",
android.widget.Toast.LENGTH_SHORT,
).show()
}
},
leadingContent = {
Icon(imageVector = Icons.Filled.Logout, contentDescription = null)
},
@ -113,6 +138,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
VideoListScreen(
source = ListSource.MyVideos,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
)
Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle },
@ -121,16 +147,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
VideoListScreen(
source = ListSource.Search,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
)
Destination.Subscriptions ->
VideoListScreen(
source = ListSource.Subscriptions,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
)
Destination.History ->
VideoListScreen(
source = ListSource.History,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
)
}
}

View file

@ -2,17 +2,30 @@ package net.jeena.jtubetv.ui.screens
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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
@ -20,9 +33,12 @@ 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
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
@ -33,10 +49,18 @@ import net.jeena.jtubetv.AppGraph
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
data class Ready(val detail: VideoDetailDto, val playUrl: String, val isHls: Boolean) : PlayerUiState
data class Ready(
val detail: VideoDetailDto,
val playUrl: String,
val isHls: Boolean,
val fileToken: String? = null,
/** If the server has a stored position, show "Resuming at …" on start. */
val resumeAtSeconds: Int? = null,
) : PlayerUiState
data class Error(val message: String) : PlayerUiState
}
@ -48,12 +72,15 @@ class PlayerViewModel(
private val _state = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading)
val state: StateFlow<PlayerUiState> = _state
var currentShortUuid: String? = null
private set
fun load(shortUuid: String) {
if (_state.value != PlayerUiState.Loading) return
currentShortUuid = shortUuid
viewModelScope.launch {
// Freshen the token before media requests hit it.
repository.me() // triggers refresh via OkHttp authenticator if needed
val result = repository.video(shortUuid)
// Freshen the token before media requests hit it (triggers refresh on 401).
repository.me()
val result = repository.video(shortUuid, userHistory = true)
result.fold(
onSuccess = { detail ->
val play = repository.playoutUrl(detail)
@ -63,15 +90,13 @@ class PlayerViewModel(
// 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.
var url = play.url
var token: String? = null
if (detail.privacy?.id != 1) {
repository.videoToken(detail.shortUUID).onSuccess { token ->
url += (if (url.contains('?')) "&" else "?") +
"videoFileToken=" +
java.net.URLEncoder.encode(token, "UTF-8")
}
token = repository.videoToken(detail.shortUUID).getOrNull()
}
_state.value = PlayerUiState.Ready(detail, url, play.isHls)
val resumeAt = detail.userHistory?.currentTime
?.takeIf { detail.duration > 0 && it > 5 && it < detail.duration * 95 / 100 }
_state.value = PlayerUiState.Ready(detail, play.url, play.isHls, token, resumeAt)
}
},
onFailure = { e ->
@ -81,10 +106,24 @@ class PlayerViewModel(
}
}
fun reportWatching(shortUuid: String, currentTimeSeconds: Int) {
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). */
fun persistExit(shortUuid: String, currentTimeSeconds: Int) {
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
}
fun retry() {
val uuid = currentShortUuid ?: return
_state.value = PlayerUiState.Loading
load(uuid)
}
companion object {
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@ -96,8 +135,10 @@ class PlayerViewModel(
}
/**
* Fullscreen ExoPlayer. Feature 6: reports currentTime to the PeerTube server every
* ~20 s so the watch shows up in the server-side history.
* 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).
*/
@Composable
fun PlayerScreen(
@ -119,8 +160,8 @@ fun PlayerScreen(
when (val s = state) {
is PlayerUiState.Loading -> PlayerLoading()
is PlayerUiState.Error -> PlayerError(s.message, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid)
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack)
}
}
@ -132,67 +173,148 @@ private fun PlayerLoading() {
}
@Composable
private fun PlayerError(message: String, onBack: () -> Unit) {
private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val initialFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { initialFocus.requestFocus() } }
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
androidx.compose.material3.Button(onClick = onBack) {
androidx.compose.material3.Text("Close ($message)")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
friendlyMediaError(message),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
androidx.compose.material3.Text(
message,
color = Color(0xFF8A8A8A),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
androidx.compose.material3.Button(
onClick = { viewModel.retry() },
modifier = Modifier
.focusRequester(initialFocus)
.focusable(),
) { Text("Retry") }
androidx.compose.material3.Button(
onClick = {
Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show()
onBack()
},
modifier = Modifier.focusable(),
) { Text("Close") }
}
}
}
internal fun friendlyMediaError(message: String): String =
when {
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("connect", ignoreCase = true) -> "Can't reach the server — check your connection"
else -> "Playback error"
}
@Composable
private fun PlayerSurface(
s: PlayerUiState.Ready,
viewModel: PlayerViewModel,
shortUuid: String,
onBack: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val player = androidx.compose.runtime.remember {
val context = LocalContext.current
val keyFocus = remember { FocusRequester() }
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 headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) {
mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}")
} else emptyMap()
val http = DefaultHttpDataSource.Factory()
.setAllowCrossProtocolRedirects(true)
AppGraph.tokenStore.accessToken?.let { http.setDefaultRequestProperties(mapOf("Authorization" to "Bearer $it")) }
val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, http)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSource)
.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 dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving)
ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSource))
.build()
}
LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } }
DisposableEffect(s.playUrl) {
val item = MediaItem.Builder()
.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)
Toast.makeText(
context,
"Resuming at ${it / 60}:${"%02d".format(it % 60)}",
Toast.LENGTH_LONG,
).show()
} ?: player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
onDispose { player.release() }
onDispose {
viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt())
player.release()
}
}
// Periodic watching report (server-side history).
// Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8).
DisposableEffect(player) {
var lastReported = 0L
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val runnable = object : Runnable {
override fun run() {
if (player.isPlaying) {
val now = System.currentTimeMillis()
val position = player.currentPosition
android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms")
if (position >= 5_000 && now - lastReported >= 20_000) {
android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s")
viewModel.reportWatching(shortUuid, (position / 1000).toInt())
viewModel.persistExit(shortUuid, (position / 1000).toInt())
lastReported = now
}
} else {
android.util.Log.d("JTubeWatch", "tick not playing")
}
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000)
handler.postDelayed(this, 5_000)
}
}
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(runnable, 5_000)
onDispose {
android.os.Handler(android.os.Looper.getMainLooper()).removeCallbacks(runnable)
}
handler.postDelayed(runnable, 5_000)
onDispose { handler.removeCallbacks(runnable) }
}
val handleKey: (android.view.KeyEvent) -> Boolean = { event ->
if (event.action == android.view.KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
-> {
player.playWhenReady = !player.playWhenReady
true
}
android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.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 -> {
player.seekTo(player.currentPosition + 10_000)
true
}
else -> false // UP/DOWN etc.: show the controller
}
} else false
}
AndroidView(
@ -201,8 +323,24 @@ private fun PlayerSurface(
useController = true
this.player = player
controllerShowTimeoutMs = 4000
isFocusable = false
descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
}
},
modifier = Modifier.fillMaxSize().background(Color.Black),
update = { },
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(keyFocus)
.focusable()
.onPreviewKeyEvent { event ->
handleKey(event.nativeKeyEvent)
},
)
}
private fun isInstanceHost(url: String, baseUrl: String?): Boolean {
val urlHost = url.substringAfter("://").substringBefore('/')
val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/')
return baseHost != null && urlHost == baseHost
}

View file

@ -19,6 +19,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
@ -38,6 +41,7 @@ fun SetupScreen(
val password = rememberSaveable { mutableStateOf("") }
val showPassword = rememberSaveable { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val busy = loggingIn.collectAsState()
val message = error.collectAsState()
val localError = rememberSaveable { mutableStateOf<String?>(null) }
@ -63,7 +67,15 @@ fun SetupScreen(
onValueChange = { url.value = it },
label = { Text("PeerTube instance URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.onPreviewKeyEvent { ke ->
when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false
}
}
.fillMaxWidth(),
colors = fieldColors(),
)
TextField(
@ -71,7 +83,15 @@ fun SetupScreen(
onValueChange = { username.value = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.onPreviewKeyEvent { ke ->
when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false
}
}
.fillMaxWidth(),
colors = fieldColors(),
)
TextField(
@ -82,7 +102,15 @@ fun SetupScreen(
visualTransformation =
if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None
else androidx.compose.ui.text.input.PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.onPreviewKeyEvent { ke ->
when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false
}
}
.fillMaxWidth(),
colors = fieldColors(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {

View file

@ -175,7 +175,7 @@ internal fun formatAge(iso: String?): String {
val age = Duration.between(Instant.parse(iso), Instant.now())
val days = age.toDays()
when {
days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now")
days >= 365 -> "${days / 365}y ago"
days >= 30 -> "${days / 30}mo ago"
days >= 1 -> "${days}d ago"
age.toHours() >= 1 -> "${age.toHours()}h ago"

View file

@ -24,6 +24,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@ -38,17 +41,35 @@ import net.jeena.jtubetv.ui.list.VideoListViewModel
fun VideoListScreen(
source: ListSource,
onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0,
viewModel: VideoListViewModel = viewModel(
key = sourceKey(source),
factory = VideoListViewModel.factory(source),
),
) {
val items = viewModel.items
// per-item focus requesters so BACK from the player can refocus the opened card
val requesters = remember(items.size) {
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
}
// Pull initial focus out of the sidebar rail and into the content grid.
val initialFocus = remember { FocusRequester() }
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) }
LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() }
}
LaunchedEffect(restoreToken) {
if (restoreToken > 0) {
if (source is ListSource.History || source is ListSource.MyVideos) {
viewModel.refresh()
}
// focus the card that was playing; fall back to the first card
val target = requesters[lastOpenedIndex] ?: initialFocus
runCatching { target.requestFocus() }
lastOpenedIndex = -1
}
}
// Scroll position survives destination switches (per source), not just player exits.
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
@ -106,11 +127,15 @@ fun VideoListScreen(
is VideoListItem.Video -> item.video
else -> return@itemsIndexed
}
val fr = remember { FocusRequester() }
requesters[index] = fr
VideoCard(
video = video,
onClick = { onOpenVideo(video) },
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
showPrivacy = source is ListSource.MyVideos,
modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier,
modifier = Modifier
.focusRequester(fr)
.also { if (index == 0) Modifier.focusRequester(initialFocus) },
)
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() }
@ -129,13 +154,28 @@ fun VideoListScreen(
@Composable
private fun SearchHeader(viewModel: VideoListViewModel) {
var text by remember { mutableStateOf(viewModel.query.value) }
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
Column(Modifier.fillMaxWidth().padding(16.dp)) {
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Search local videos") },
label = { Text("Type a search term, then press the search action") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Search,
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSearch = { viewModel.triggerSearch(text); focusManager.clearFocus() },
),
modifier = Modifier
.fillMaxWidth()
.onPreviewKeyEvent { ke ->
when (ke.key) {
Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true }
Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true }
else -> false
}
},
)
Button(
onClick = { viewModel.triggerSearch(text) },