diff --git a/.gitignore b/.gitignore index 0935029..f89a0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ /app/build /captures .externalNativeBuild -.cxx \ No newline at end of file +.cxxreports/*-shots/ diff --git a/TESTS.md b/TESTS.md index 5d79d5a..46dbb1b 100644 --- a/TESTS.md +++ b/TESTS.md @@ -71,7 +71,7 @@ APK version + device. - [ ] T17 Click remote video → plays or fails *gracefully* (a remote video's media is on the remote host; MVP may fail to resolve — must show error toast/message + back, never crash/freeze). ### 5 — History -- [ ] T18 History lists server-side history newest-first, incl. both public and private watched entries. +- [ ] T18 History lists server-side history newest-first. NOTE (verified 2026-10-10): PeerTube's history endpoint OMITS private videos — private test entries will not appear; adjust expectations accordingly (app is fine). - [ ] T19 Play an entry to ≥ 20 s, leave (back) → entry is present/updated in history when re-opening the screen (server round-trip within ~1 s; app must not show stale cache as fresh — reload on screen entry). ### 6 — Playback diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index cee4fe0..50e2eee 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -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 } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index b615684..247057d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -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() diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt index 043da62..c96c5bc 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -54,6 +54,27 @@ abstract class PagedListViewModel : 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 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 91d4d8a..a884dc0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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, - 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() } } } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index 900a3f7..18325e9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(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, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index b3f8cbf..7e27467 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -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.Loading) val state: StateFlow = _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 } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt index b0f2614..fc01343 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -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(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)) { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 5eaa1c1..8d67b29 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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" diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 3bb45e0..6271151 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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() + } // 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) },