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:
parent
43056065e1
commit
9d89c1c060
11 changed files with 411 additions and 117 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,4 +7,4 @@
|
|||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
.cxxreports/*-shots/
|
||||
|
|
|
|||
2
TESTS.md
2
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue