diff --git a/BACKLOG.md b/BACKLOG.md index 4b29b20..f9e6182 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,14 +13,6 @@ Status vocabulary: `todo` · `doing` · `done`. | 4 | Subscription videos | done | Verified incl. remote (federated) channels | | 5 | Watch history (server) | done | read-only grid | | 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | -| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon | - -## Discussed but deferred (2026-10-12, while reworking the player controls) - -- Like/dislike display: counts are in the video detail DTO; rating (own like via - `PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later. -- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration` - on the player; language selection in the controls overlay. ## Next up (v0.2+) 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 496ca1f..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -69,47 +69,45 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // Login accepts email addresses too; the login input is not a valid - // handle for /accounts/{handle}, so default it, refreshMyProfile() - // corrects it right after (and on every app start). - store.accountName = username.substringBefore('@') - store.instanceName = runCatching { network.api.serverConfig().instance.name }.getOrNull() - // /users/me fills displayName + avatar; failure is not fatal because - // the profile is refreshed again on the next app start. - refreshMyProfile() + // 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 { + 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.instanceName = try { + meApi.serverConfig().instance.name + } catch (_: Exception) { null } + val me = meApi.me() + store.accountName = me.username + store.userDisplayName = me.displayName ?: me.username + me.avatars.maxByOrNull { it.width }?.let { avatar -> + store.userAvatarUrl = avatar.fileUrl + ?.takeIf { url -> url.startsWith("http") } + ?: apiUrlOf(base) + avatar.path + } + } catch (_: Exception) { + store.accountName = username.substringBefore('@') // best effort fallback + } } suspend fun me(): Result = runCatching { network.api.me() } suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } - /** Display name + avatar URL of the logged-in user, from /users/me. */ - data class UserProfile(val displayName: String, val avatarUrl: String?) - - /** - * Fetches and persists the profile of the logged-in user. Called after login - * AND on every app start with a stored session: it used to run only at login - * inside a swallow-all try, so sessions from earlier app versions showed the - * raw username and no avatar. Returns null on failure. - */ - suspend fun refreshMyProfile(): UserProfile? { - // /users/me carries displayName + avatars inside `account` (verified - // live v8.3.1); the flat fields exist on some older versions only. - val me = runCatching { network.api.me() }.getOrNull() ?: return null - val name = me.account?.displayName ?: me.displayName ?: me.username - val avatars = me.account?.avatars?.takeIf { it.isNotEmpty() } ?: me.avatars - store.accountName = me.username - val displayName = name - store.userDisplayName = displayName - val avatarUrl = avatars.maxByOrNull { it.width }?.let { avatar -> - avatar.fileUrl?.takeIf { it.startsWith("http") } - // lazy-static avatars live at the instance root, NOT under /api/v1 - ?: store.baseUrl?.let { base -> normalizeBaseUrl(base) + avatar.path } - } - store.userAvatarUrl = avatarUrl - return UserProfile(displayName, avatarUrl) - } - /** Display name of a (possibly foreign) instance, via that instance's /config. */ suspend fun instanceName(rawHost: String): Result = runCatching { val base = "https://" + rawHost diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 4507861..91d3777 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -165,15 +165,6 @@ data class ChannelStats( @Serializable data class UserDto( val username: String, - // /users/me does NOT return these at top level on newer PeerTube versions — - // the account object carries displayName + avatars (verified live v8.3.1). val displayName: String? = null, val avatars: List = emptyList(), - val account: UserAccountDto? = null, -) - -@Serializable -data class UserAccountDto( - @SerialName("displayName") val displayName: String? = null, - val avatars: List = emptyList(), ) \ No newline at end of file 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 8bc14bd..e6e542f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,10 +19,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _sessionState - /** Display name + avatar of the logged-in user (for the drawer header). */ - private val _userProfile = MutableStateFlow(null) - val userProfile: StateFlow = _userProfile - /** * Bumped on every logout and successful login: all list ViewModels and saved * UI state are keyed by it, so switching accounts never shows the previous @@ -34,19 +30,8 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit val loggingIn = MutableStateFlow(false) init { - if (repository.hasSession()) { - _sessionState.value = SessionState.Ready - // Re-fetch the profile on every app start: stored sessions from older - // app versions may lack displayName/avatar (or the login-time fetch - // failed back then). Failure keeps the stored/fallback values. - refreshProfile() - } else { - _sessionState.value = SessionState.NeedsSetup - } - } - - private fun refreshProfile() { - viewModelScope.launch { _userProfile.value = repository.refreshMyProfile() } + _sessionState.value = + if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup } fun login(baseUrl: String, username: String, password: String) { @@ -58,7 +43,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit if (result.isSuccess) { _sessionState.value = SessionState.Ready sessionEpoch.value += 1 - refreshProfile() } else { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" @@ -83,7 +67,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() - _userProfile.value = null _sessionState.value = SessionState.NeedsSetup sessionEpoch.value += 1 } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index dca398c..a723e8e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -74,7 +74,9 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { - viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() } + viewModelScope.launch { + channelHeader = repository.videoChannel(s.handle).getOrNull() + } } } 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 74cf99a..1cd6eea 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 @@ -259,7 +259,7 @@ fun HomeRowsScreen( videos = viewModel.subItems, progressOf = { null }, onOpenVideo = onOpenVideo, - onLeftAtStart = onOpenDrawer, + onLeftAtStart = { }, ) } } 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 cd1c618..a6f5aad 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 @@ -1,8 +1,8 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -12,13 +12,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.AccountCircle @@ -39,16 +36,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput @@ -56,8 +51,6 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.Text import net.jeena.jtubetv.AppGraph -import net.jeena.jtubetv.R -import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -74,28 +67,23 @@ enum class Destination(val title: String) { } /** - * Home = SmartTube-style: persistent top bar with the app logo + title on the - * left and the account button (avatar + display name, opens the menu) on the - * right; the menu drawer slides in below the bar so the logo/title stay in the - * same place whether the drawer is open or not. Content below is key(epoch) so - * an account switch rebuilds all state. + * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, + * full-width rows/list content. The menu drawer slides in when wanted (avatar or + * the search/BACK flows) and everything is key(epoch) so an account switch + * rebuilds all state. */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() - val userProfile by viewModel.userProfile.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle - var channelName by rememberSaveable { mutableStateOf(null) } // display name // NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } - - /** Close the drawer; focus falls back into the content pane. */ - val dismissDrawer = { drawerOpen = false } + val searchFocus = remember { FocusRequester() } // 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). @@ -112,7 +100,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi // BACK order (TV standard): player (own handler) → drawer close → channel // drilldown → non-Home destination → Home; from Home: double-press exits. BackHandler(enabled = drawerOpen && playingVideo == null) { - dismissDrawer() + drawerOpen = false + runCatching { searchFocus.requestFocus() } } BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { channelDetails = null @@ -144,48 +133,35 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi playingVideo == null ) { when { - drawerOpen -> { dismissDrawer(); true } + drawerOpen -> { drawerOpen = false; true } channelDetails != null -> { channelDetails = null; true } else -> false } } else false }, ) { + androidx.compose.runtime.key(epoch) { Column { - // Persistent top bar: logo/title left, account button right. + // SmartTube-style top bar: red round search + avatar opening the menu. TopBar( + onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, - profile = userProfile, + searchFocus = searchFocus, ) - androidx.compose.runtime.key(epoch) { - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 8.dp), - ) { - when { - // Channel videos page (opened from the Channels screen or the - // player's channel icon) overrides the destination content. - channelDetails != null -> - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelName ?: ""), - onOpenVideo = { playingVideo = it.shortUUID }, - restoreToken = restoreToken, - epoch = epoch, - onOpenDrawer = { drawerOpen = true }, - upToTopBar = { drawerOpen = true }, - ) - else -> when (destination) { + Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), + ) { + when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - // UP over the first row opens the drawer. - upToTopBar = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -194,7 +170,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -208,7 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> VideoListScreen( @@ -217,7 +193,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -226,38 +202,31 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) - } - } - } - - // Slide-in menu drawer, below the top bar so logo/title stay put. - if (drawerOpen) { - MenuDrawer( - selected = destination, - onSelect = { - destination = it - channelDetails = null - drawerOpen = false - }, - onSignOut = { viewModel.logout() }, - onDismiss = { dismissDrawer() }, - ) - } } } } + } + + // Slide-in menu drawer (SmartTube style: hidden until wanted). + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { drawerOpen = false }, + ) + } if (playingVideo != null) { PlayerScreen( shortUuid = playingVideo!!, onBack = { playingVideo = null }, - onOpenChannel = { handle, displayName -> - playingVideo = null - channelDetails = handle - channelName = displayName - }, ) } } @@ -273,109 +242,120 @@ private fun Destination.icon(): ImageVector = Destination.History -> Icons.Filled.History } -/** - * Persistent top bar: logo + "JTube TV" on the left (unchanged while the drawer - * is open, so they anchor the layout) and the account button on the right. - */ +/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ @Composable private fun TopBar( + onSearchClick: () -> Unit, onAccountClick: () -> Unit, - profile: PeerTubeRepository.UserProfile?, + searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), ) { - // The launcher icon is an adaptive icon, which Compose can't paint - // directly — the launcher look is its two layers: dark background + - // foreground glyph (both vector drawables). - // Bottom-aligned to the text line and nudged up so the logo's bottom - // edge sits on the text baseline, not below it. - Box( - modifier = Modifier - .align(Alignment.Bottom) - .padding(bottom = 17.dp) // slightly below the text baseline - .size(32.dp) // match the avatar row so both headers align - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFF101010)), - ) { - // Zoom past the adaptive-icon safe zone: the glyph is only ~45% of - // the 108dp viewport, so Fit would render it tiny. - Image( - painter = painterResource(R.drawable.icon_foreground), - contentDescription = "JTube TV logo", - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .scale(2.1f), - ) - } - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = "JTube TV", - color = Color.White, - style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + RoundTopButton( + bg = Color(0xFFCE2B2B), + icon = Icons.Filled.Search, + desc = "Search", + onClick = onSearchClick, + focusRequester = searchFocus, ) - Spacer(modifier = Modifier.weight(1f)) - // Account indicator: avatar + display name. Purely visual on TV (D-pad - // focus and activation stay in the content/drawer); a click (mouse/touch) - // still opens the menu drawer. + // "Who is logged in" — account button: avatar circle + display name (SmartTube). + val accountFocused = remember { mutableStateOf(false) } Row( - modifier = Modifier.clickable(onClick = onAccountClick), + modifier = Modifier + .onFocusChanged { accountFocused.value = it.isFocused } + .background(Color.Transparent) + .then( + if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) + else Modifier + ) + .focusable() + .dpadCenterActivatable(onAccountClick) + .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { - AccountAvatar( - url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl, - size = 40.dp, - ) + val avatarUrl = AppGraph.tokenStore.userAvatarUrl + if (avatarUrl != null) { + coil.compose.AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(avatarUrl) + .size(96) + .build(), + contentDescription = "Account", + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + ) + } else { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Account", + tint = Color(0xFFCCCCCC), + ) + } + } Text( - text = profile?.displayName - ?: AppGraph.tokenStore.userDisplayName - ?: AppGraph.tokenStore.username?.substringBefore('@') - ?: "Account", - color = Color(0xFFD0D0D0), + text = AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFB0B0B0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(end = 8.dp) - // keep the button compact; the bar never wraps under a long name - .widthIn(max = 160.dp), - maxLines = 1, + modifier = Modifier.padding(end = 8.dp), ) } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "JTube TV", + color = Color(0xFF9A9A9A), + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.align(Alignment.CenterVertically), + ) } } + @Composable -private fun AccountAvatar(url: String?, size: androidx.compose.ui.unit.Dp) { - if (url != null) { - coil.compose.AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) - .data(url) - .size(96) - .build(), - contentDescription = "Account", - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(Color(0xFF262626)), - ) - } else { - Box( - modifier = Modifier - .size(size) - .clip(CircleShape) - .background(Color(0xFF262626)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = "Account", - tint = Color(0xFFCCCCCC), +private fun RoundTopButton( + bg: Color, + icon: ImageVector, + desc: String, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .onFocusChanged { focused = it.isFocused } + .background(bg) + .then( + if (focused) Modifier.border( + 3.dp, Color.White, CircleShape + ) else Modifier ) - } + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .focusable() + .dpadCenterActivatable(onClick) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + ) } } @@ -401,9 +381,7 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - // Logo/title live in the persistent top bar above; only a slim - // top gap here so the item list starts below the bar. - .padding(top = 8.dp, bottom = 24.dp) + .padding(vertical = 24.dp) .onPreviewKeyEvent { ke -> // RIGHT from the drawer closes it; focus returns to the top bar. if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && @@ -414,6 +392,12 @@ private fun MenuDrawer( } else false }, ) { + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), + ) Destination.entries.forEachIndexed { index, dest -> MenuDrawerItem( icon = dest.icon(), 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 b40baf9..8254ee1 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 @@ -11,7 +11,6 @@ import androidx.activity.compose.BackHandler import androidx.media3.ui.AspectRatioFrameLayout import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,7 +25,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Forward10 -import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material3.CircularProgressIndicator @@ -47,15 +45,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -177,9 +172,6 @@ class PlayerViewModel( fun PlayerScreen( shortUuid: String, onBack: () -> Unit, - // From the paused/controls overlay's channel icon: (handle, displayName), - // must also exit the player screen first. - onOpenChannel: (String, String) -> Unit = { _, _ -> }, viewModel: PlayerViewModel = viewModel( key = "player$shortUuid", factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), @@ -197,7 +189,7 @@ fun PlayerScreen( when (val s = state) { is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) - is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack) } } @@ -259,19 +251,16 @@ private fun PlayerSurface( viewModel: PlayerViewModel, shortUuid: String, onBack: () -> Unit, - onOpenChannel: (String, String) -> Unit, ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } var paused by remember { mutableStateOf(false) } - // True while the player hasn't reached STATE_READY yet (initial buffering OR - // a re-buffer after seeking) — shows an indicator until media really plays - // (issue #8: the Loading screen ended too early and the rest was black). - var buffering by remember { mutableStateOf(true) } + var playerViewRef by remember { mutableStateOf(null) } // Key press that was consumed at ACTION_DOWN — its trailing ACTION_UP must not // re-reveal the native controller right after a play/pause toggle. var consumedDownKey by remember { mutableStateOf(0) } - // Overlay focus/position state for the controls (#4). + // Overlay focus/position state for the paused controls (#4). + val playPauseFocus = remember { FocusRequester() } val seekBarFocus = remember { FocusRequester() } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } @@ -301,12 +290,12 @@ private fun PlayerSurface( .build() } - // Pause: move focus into the overlay (the seek bar — the only control); resume: + // Pause: move focus into the overlay (the Play button, like SmartTube); resume: // back to the plain key handler so the next OK pauses again. LaunchedEffect(paused) { if (paused) { positionMs.longValue = player.currentPosition - runCatching { seekBarFocus.requestFocus() } + runCatching { playPauseFocus.requestFocus() } } else { runCatching { keyFocus.requestFocus() } } @@ -360,12 +349,11 @@ private fun PlayerSurface( player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { paused = !isPlaying - } - - override fun onPlaybackStateChanged(playbackState: Int) { - buffering = playbackState != Player.STATE_READY + if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar } }) + // Controller does not flash at start; only UP/DOWN/other keys reveal it. + playerViewRef?.hideController() onDispose { viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) player.release() @@ -402,10 +390,7 @@ private fun PlayerSurface( onBack() true } - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - // Keyboard users: Space/Enter must pause/resume like the remote OK. - KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER -> { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true } @@ -422,38 +407,21 @@ private fun PlayerSurface( } else false } - // The native PlayerView controller is fully disabled (useController = false, - // mouse hover must NOT reveal it) — our own overlay is the only controls UI: - // persistent while paused, and transient (5 s) on any non-media key while - // playing. - var controlsVisible by remember { mutableStateOf(false) } - var surfaceFocused by remember { mutableStateOf(true) } - LaunchedEffect(controlsVisible, paused) { - if (controlsVisible && !paused) { - kotlinx.coroutines.delay(5000) - controlsVisible = false - runCatching { keyFocus.requestFocus() } - } - } - Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .focusRequester(keyFocus) - .onFocusChanged { surfaceFocused = it.isFocused } .focusable() .onPreviewKeyEvent { ke -> val code = ke.nativeKeyEvent.keyCode when { - // BACK/Esc leave the player in any state, from any focus. - code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { + paused && (code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE) -> { onBack(); true } - // While paused our overlay has focus and handles everything. + // While paused the overlay handles arrows + CENTER (#4); unhandled + // keys must not wake the native bar either. paused -> false - // Transient controls hold focus while playing: let them move it. - controlsVisible && !surfaceFocused -> false else -> { val handled = handleKey(ke.nativeKeyEvent) if (!handled) { @@ -462,17 +430,15 @@ private fun PlayerSurface( ) { consumedDownKey = 0 // trailing UP of a handled press } else { - // UP/DOWN etc. reveal OUR controls, focus the Play button. - controlsVisible = true - if (surfaceFocused) { - runCatching { seekBarFocus.requestFocus() } - } + // UP/DOWN etc. reveal the controls briefly + playerViewRef?.showController() } true } else { if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { consumedDownKey = ke.nativeKeyEvent.keyCode } + if (player.playWhenReady) playerViewRef?.hideController() true } } @@ -486,41 +452,27 @@ private fun PlayerSurface( // screen regardless of the letterbox geometry (issue #1). (LayoutInflater.from(ctx) .inflate(R.layout.player_video_view, null) as PlayerView).apply { - // Native controller must never appear (issue: hovering with the - // mouse showed the old play UI) — we own the controls entirely. - useController = false + useController = true resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT this.player = player isFocusable = false + // We draw our own pause controls (#4); the native bar must only + // appear where we call showController explicitly. + setControllerAutoShow(false) descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS - // A click (mouse) reveals our overlay instead. - setOnClickListener { controlsVisible = true } + playerViewRef = this } }, modifier = Modifier.fillMaxSize(), ) - // Cover the picture with the loading indicator until the player is really - // ready to render — metadata resolution alone (the Loading state) is not - // enough (issue #8). - if (buffering) { - Box( - Modifier - .fillMaxSize() - .background(Color.Black) - .pointerInput(Unit) {}, // swallow taps/clicks while covering - contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } - } - // Controls: SmartTube-style (title + channel above, then the seek bar). - // Persistent when paused, transient while playing. - if (paused || controlsVisible) { + // Paused → SmartTube-style controls: seek bar over transport buttons over info. + if (paused) { PausedControls( detail = s.detail, player = player, positionMs = positionMs, - playing = !paused, + playPauseFocus = playPauseFocus, seekBarFocus = seekBarFocus, - onOpenChannel = onOpenChannel, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -540,199 +492,257 @@ private fun seekOverlay( } /** - * SmartTube-style controls (#4): channel icon + title + channel line above the - * seek bar, position/duration below it; on a gradient so the video stays visible. - * Persistent when paused, transient while playing (any non-media key reveals it, - * auto-hides after 5 s). + * SmartTube-style paused controls (#4): a big always-visible seek bar ABOVE the info + * bar (it used to hide behind the semi-transparent info panel), transport buttons the + * D-pad can actually select (arrows pick, OK activates), then the info panel. */ @Composable private fun PausedControls( detail: VideoDetailDto, player: ExoPlayer, positionMs: androidx.compose.runtime.MutableLongState, - // True while play runs (transient overlay): keeps the times ticking; - // false = persistent paused overlay. - playing: Boolean, + playPauseFocus: FocusRequester, seekBarFocus: FocusRequester, - // (handle, displayName) — opens the channel's videos page; exits the player. - onOpenChannel: (String, String) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { val durationMs = detail.duration * 1000L - // While playing, the position text must keep counting. - LaunchedEffect(playing) { - while (playing) { - positionMs.longValue = player.currentPosition - kotlinx.coroutines.delay(1000) - } - } - // SmartTube-style: controls sit on a gradient over the video (the picture - // stays visible above), no opaque slab, no boxed buttons, no label text. Column( modifier = modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - 0f to Color(0x00000000), - 0.25f to Color(0xCC000000), - 1f to Color(0xF0000000), - ) - ) - .padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 14.dp), + .background(Color(0xDC000000)) + .padding(horizontal = 24.dp, vertical = 18.dp), ) { - // Channel icon: a button — opens the channel's videos page (its avatar is - // optional: federated channels may have none). - var channelFocused by remember { mutableStateOf(false) } - val channelFocus = remember { FocusRequester() } - var channelModifier: Modifier = Modifier.size(48.dp) - channelModifier = channelModifier - .focusRequester(channelFocus) - .onFocusChanged { channelFocused = it.isFocused } - .onPreviewKeyEvent { ke -> - if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (ke.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_DOWN -> { - runCatching { seekBarFocus.requestFocus() }; true - } - KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> true - else -> false - } - } - .dpadCenterActivatable { - onOpenChannel( - detail.channel.name + "@" + (detail.channel.host ?: ""), - detail.channel.displayName, - ) - } - .focusable() - .then(if (channelFocused) Modifier.border(3.dp, Color.White, CircleShape) else Modifier) - Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = detail.channel.avatars.maxByOrNull { it.width } - if (avatar != null) { - coil.compose.AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), - contentDescription = "Open channel", - contentScale = ContentScale.Crop, - modifier = channelModifier - .clip(CircleShape), - ) - } - Column(modifier = Modifier.padding(start = if (avatar != null) 14.dp else 0.dp)) { - Text( - text = detail.name, - color = Color.White, - style = MaterialTheme.typography.titleLarge, - maxLines = 2, - ) - Text( - text = detail.channel.displayName + " · " + formatViews(detail.views) + - " · " + formatPublished(detail.publishedAt), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 2.dp), - ) - } - } - // The seek bar is the only control: focused = scrub, OK = play/pause. SeekBarRow( positionMs = positionMs, durationMs = durationMs, seekBarFocus = seekBarFocus, + playPauseFocus = playPauseFocus, player = player, - // UP climbs to the channel icon when it exists. - channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null, - modifier = Modifier.padding(top = 16.dp), - ) - // Position / duration under the bar, right-aligned. - Text( - text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + - formatDuration((durationMs / 1000).toInt()), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .align(Alignment.End) - .padding(top = 8.dp), ) + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 14.dp), + ) { + PauseControl( + icon = Icons.Filled.Replay10, + label = "Back 10 s", + seekBarFocus = seekBarFocus, + isLeftEdge = true, + isActive = false, + onClick = { seekOverlay(player, positionMs, -10_000L, durationMs) }, + ) + PauseControl( + icon = Icons.Filled.PlayArrow, + label = "Play", + seekBarFocus = seekBarFocus, + focusRequester = playPauseFocus, + isActive = true, + onClick = { player.playWhenReady = true }, + ) + PauseControl( + icon = Icons.Filled.Forward10, + label = "Forward 10 s", + seekBarFocus = seekBarFocus, + isRightEdge = true, + isActive = false, + onClick = { seekOverlay(player, positionMs, 10_000L, durationMs) }, + ) + } + InfoRow(detail, positionMs.longValue, Modifier.padding(top = 16.dp)) } } -/** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ +/** Focusable + seekable progress bar: arrows scrub ±10 s, OK starts playback. */ @Composable private fun SeekBarRow( positionMs: androidx.compose.runtime.MutableLongState, durationMs: Long, seekBarFocus: FocusRequester, + playPauseFocus: FocusRequester, player: ExoPlayer, - channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay) - modifier: Modifier = Modifier, ) { var barFocused by remember { mutableStateOf(false) } val fraction = if (durationMs > 0) (positionMs.longValue.toFloat() / durationMs).coerceIn(0f, 1f) else 0f - Box( - modifier = modifier - .fillMaxWidth() - .height(44.dp) - .focusRequester(seekBarFocus) - .onFocusChanged { barFocused = it.isFocused } - .onPreviewKeyEvent { ke -> - if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (ke.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { - seekOverlay(player, positionMs, -10_000L, durationMs); true + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = formatDuration((positionMs.longValue / 1000).toInt()), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + ) + Box( + modifier = Modifier + .weight(1f) + .height(44.dp) + .focusRequester(seekBarFocus) + .onFocusChanged { barFocused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { + seekOverlay(player, positionMs, -10_000L, durationMs); true + } + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + seekOverlay(player, positionMs, 10_000L, durationMs); true + } + KeyEvent.KEYCODE_DPAD_UP -> true // topmost control — stay here + KeyEvent.KEYCODE_DPAD_DOWN -> { + runCatching { playPauseFocus.requestFocus() }; true + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY -> { + player.playWhenReady = true // OK on the bar starts playback + true + } + else -> false } - KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - seekOverlay(player, positionMs, 10_000L, durationMs); true - } - KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false - KeyEvent.KEYCODE_DPAD_DOWN -> true - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { - player.playWhenReady = !player.playWhenReady - true - } - else -> false + } + .focusable() + .background( + if (barFocused) Color(0x22FFFFFF) else Color.Transparent, + RoundedCornerShape(22.dp), + ) + .padding(horizontal = 12.dp), + ) { + Canvas( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxWidth() + .height(if (barFocused) 14.dp else 10.dp) + // rounded ends for track and progress + .clip(RoundedCornerShape(7.dp)), + ) { + drawRoundRect(color = Color(0x44FFFFFF), cornerRadius = CornerRadius(99f)) + val w = size.width * fraction + if (w > 1f) { + drawRoundRect( + color = Color(0xFFCE2B2B), + size = Size(w, size.height), + cornerRadius = CornerRadius(99f), + ) } } - .focusable() - .padding(horizontal = 4.dp), - ) { - // Thin SmartTube-style track: rounded, red progress, white round knob. - Canvas( - modifier = Modifier - .align(Alignment.CenterStart) - .fillMaxWidth() - .height(if (barFocused) 12.dp else 6.dp) - .clip(RoundedCornerShape(7.dp)), - ) { - drawRoundRect(color = Color(0x55FFFFFF), cornerRadius = CornerRadius(99f)) - val w = size.width * fraction - if (w > 1f) { - drawRoundRect( - color = Color(0xFFCE2B2B), - size = Size(w, size.height), - cornerRadius = CornerRadius(99f), - ) - } - } - // White round knob on top of the track, always visible (SmartTube). - Canvas( - modifier = Modifier - .align(Alignment.CenterStart) - .fillMaxWidth() - .height((if (barFocused) 18.dp else 14.dp).coerceAtLeast(14.dp)), - ) { - drawCircle( - color = Color.White, - radius = (if (barFocused) 9.dp else 7.dp).toPx(), - center = Offset(fraction * size.width, size.height / 2f), - ) } + Text( + text = formatDuration((durationMs / 1000).toInt()), + color = Color(0xFFAAAAAA), + style = MaterialTheme.typography.labelMedium, + ) } } +/** Round transport button; arrows move focus (bar above via [seekBarFocus]), OK clicks. */ +@Composable +private fun PauseControl( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + seekBarFocus: FocusRequester, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, + isLeftEdge: Boolean = false, + isRightEdge: Boolean = false, + isActive: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + var m: Modifier = Modifier.size(92.dp) + if (focusRequester != null) m = m.focusRequester(focusRequester) + Box( + modifier = m + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + runCatching { seekBarFocus.requestFocus() }; true + } + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> true + KeyEvent.KEYCODE_DPAD_LEFT -> isLeftEdge + KeyEvent.KEYCODE_DPAD_RIGHT -> isRightEdge + else -> false + } + } + .dpadCenterActivatable(onClick) + .focusable() + .background( + when { + focused && isActive -> Color(0xFFCE2B2B) + focused -> Color.White + else -> Color(0xFF2A2A2A) + }, + RoundedCornerShape(14.dp), + ), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = when { + focused && isActive -> Color.White + focused -> Color(0xFF101010) + else -> Color.White + }, + modifier = Modifier.size(42.dp), + ) + } + Text( + text = label, + color = if (focused) Color.White else Color(0xFFB0B0B0), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 6.dp), + ) + } +} +/** Bottom info panel: channel (icon + name), title, views, date, position. */ +@Composable +private fun InfoRow(detail: VideoDetailDto, positionMs: Long, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + val avatar = detail.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = detail.name, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + ) + Text( + text = detail.channel.displayName, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt), + color = Color(0xFF9A9A9A), + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge) + } + } +} private fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0) @@ -741,23 +751,23 @@ private fun formatViews(views: Long): String = else -> "$views views" } -/** Absolute published date + time in the device locale (relative age was too coarse). */ -private fun formatPublished(iso: String?): String { +private fun formatAge(iso: String?): String { if (iso.isNullOrBlank()) return "" return try { - java.time.ZonedDateTime.ofInstant( - java.time.Instant.parse(iso), - java.time.ZoneId.systemDefault(), - ).format( - java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm", java.util.Locale.getDefault()) - ) + val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago" + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } } catch (e: java.time.format.DateTimeParseException) { - e.printStackTrace() - "" // unknown date → omit it + "" } } - private fun formatDuration(seconds: Int): String { val h = seconds / 3600 val m = (seconds % 3600) / 60 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 17d7681..02e43b3 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 @@ -2,12 +2,9 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed @@ -18,24 +15,21 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.R import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel @@ -86,19 +80,9 @@ fun VideoListScreen( key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - if (source is ListSource.Channel) { - // Fetch the banner/avatar/counts for the channel page header. - LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) } - } - - // Cards-per-row of the adaptive grid — LEFT from the first card of ANY row - // must open the drawer (issue #7: "every time I'm all the way on the left"). - val cellsPerRow by remember { - androidx.compose.runtime.derivedStateOf { - val info = gridState.layoutInfo - val w = info.visibleItemsInfo.firstOrNull()?.size?.width ?: 0 - if (w > 0) (info.viewportSize.width / w).coerceIn(1, 10) else 0 - } + when (source) { + is ListSource.Search -> SearchHeader(viewModel) + else -> {} } if (viewModel.loadingInitial && items.isEmpty()) { @@ -143,15 +127,6 @@ fun VideoListScreen( state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { - // Page headers (search field / channel banner) live INSIDE the grid so - // they scroll with the content and never stack under the cards. - item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - when (source) { - is ListSource.Search -> SearchHeader(viewModel) - is ListSource.Channel -> ChannelHeader(channelHeader = viewModel.channelHeader) - else -> {} - } - } itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video @@ -165,7 +140,7 @@ fun VideoListScreen( onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, compact = true, - onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null, + onLeftAtStart = if (index == 0) onOpenDrawer else null, onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), @@ -219,54 +194,6 @@ private fun SearchHeader(viewModel: VideoListViewModel) { } } -/** Channel page header: banner strip, then avatar + display name + handle + counts. */ -@Composable -private fun ChannelHeader(channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?) { - if (channelHeader == null) return - androidx.compose.foundation.layout.Column { - val banner = channelHeader.banners.maxByOrNull { it.width } - if (banner != null) { - AsyncImage( - model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = channelHeader.avatars.maxByOrNull { it.width } - if (avatar != null) { - AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .padding(start = 16.dp, top = 10.dp) - .size(52.dp) - .clip(androidx.compose.foundation.shape.CircleShape), - ) - } - Column(modifier = Modifier.padding(start = 16.dp)) { - Text( - text = channelHeader.displayName, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - ) - Text( - text = (if (channelHeader.videosCount > 0) channelHeader.videosCount.toString() + " videos · " else "") + - channelHeader.name + "@" + channelHeader.host, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } -} - private fun itemStableKey(item: VideoListItem): String = when (item) { is VideoListItem.Video -> "v" + item.video.id diff --git a/docs/agent-container.md b/docs/agent-container.md index 5d6b66f..48a15d2 100644 --- a/docs/agent-container.md +++ b/docs/agent-container.md @@ -9,12 +9,6 @@ emulator works, and what needs to exist again after a rebuild. `scripts/start-emulator.sh`). **Headless is the default**; set `JTUBE_EMU_MODE=windowed` if you want the Qt window (e.g. for desktop interaction testing). View it via scrcpy either way. -- **Only the host can start the emulator** (by design — no SSH from the - container to the host, no SDK/KVM inside the container). If the agent - finds no emulator, it should tell the user to run - `./scripts/start-emulator.sh` on the host rather than try to work around - it. Reachability check: `adb connect rutherford:5556` then - `adb -s rutherford:5556 shell getprop sys.boot_completed` → `1`. - **Agent container**: image `agent-container:latest`, container name pattern `ac--`, launched with **`--network host`** — it shares the host's network namespace. This is load-bearing: see below. @@ -52,8 +46,7 @@ So inside the container: `/tmp/jtube-adbkeys/*.pub`) and launches the emulator with `ADB_VENDOR_KEYS` pointing at it. To trust a new client: put its `~/.android/adbkey.pub` into `/tmp/jtube-adbkeys/` **before** launching. -- Logs: `/tmp/jtube-emu.log` (QEMU), `/tmp/jtube-socat.log` (bridge), - `/tmp/jtube-adbkeys-combined` (rebuilt each launch). +- Logs: `/tmp/jtube-emu.log` (QEMU) and `/tmp/jtube-emu-run.log` (script). - Viewing: `scrcpy -s emulator-5600` on the host, or from any LAN machine: `adb connect rutherford:5556 && scrcpy -s 127.0.0.1:5556`. diff --git a/scripts/bridge-emulator.sh b/scripts/bridge-emulator.sh new file mode 100755 index 0000000..5fc8ed2 --- /dev/null +++ b/scripts/bridge-emulator.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# bridge-emulator.sh — (host-side) expose the emulator's adbd to the LAN so agents +# inside the container/other machines can use it: binds :5557 -> 127.0.0.1:5601. +# The emulator console transport (emulator-5600) stays local-only; this bridges the +# adbd TCP port 5601. +set -euo pipefail +ADBD_PORT=5601 +BRIDGE_PORT=5557 + +# start emulator if not running +ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb" +if ! "$ADB" devices | grep -q "emulator-5600.*device"; then + echo "emulator-5600 not running — starting it…" + ADB_VENDOR_KEYS="$HOME/.android/adbkey" setsid nohup \ + "${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/emulator/emulator" -avd jtubesc -port 5600 \ + -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim \ + > /tmp/jtube-emu.log 2>&1 < /dev/null & + echo "waiting for boot (log: /tmp/jtube-emu.log)…" + for i in $(seq 1 60); do + ok=$("$ADB" -s emulator-5600 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } + sleep 5 + done +fi + +# keep JTube TV installed + running +APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" +if [ -f "$APK" ]; then + "$ADB" -s emulator-5600 install -r "$APK" >/dev/null && echo "JTube TV installed" + "$ADB" -s emulator-5600 shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" +fi + +# the bridge (bind all LAN addresses) +if ! ss -tln 2>/dev/null | grep -q ":$BRIDGE_PORT"; then + setsid nohup socat TCP-LISTEN:$BRIDGE_PORT,bind=0.0.0.0,fork,reuseaddr \ + TCP:127.0.0.1:$ADBD_PORT > /tmp/jtube-bridge.log 2>&1 < /dev/null & + echo "bridge: :$BRIDGE_PORT -> 127.0.0.1:$ADBD_PORT (pid $!)" +fi + +echo "From this host: adb connect localhost:$BRIDGE_PORT" +echo "From other machines: adb connect rutherford:$BRIDGE_PORT" \ No newline at end of file diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index fdb9af7..5995100 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -62,13 +62,11 @@ if [ "$MODE" = windowed ]; then else echo "JTUBE_EMU_MODE=windowed but no display detected — falling back to headless" WINDOWED=0 - ACCEL+=(-no-window -gpu swiftshader_indirect) + ACCEL+=(-no-window) fi else WINDOWED=0 - # Headless has no X display, so -gpu host/GLX can't init EGL ("Failed to get EGL - # display") and the emulator dies on launch — fall back to software rendering. - ACCEL+=(-no-window -gpu swiftshader_indirect) + ACCEL+=(-no-window) echo "headless (default; JTUBE_EMU_MODE=windowed for a window) — no emulator window" fi if [ -e /dev/kvm ]; then @@ -81,27 +79,21 @@ fi # blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead. # Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from the # agent container: docker exec sh -c 'cat ~/.android/adbkey.pub' > /tmp/jtube-adbkeys/agent-container.pub -mkdir -p /tmp/jtube-adbkeys VKEYS=/tmp/jtube-adbkeys-combined { cat "$HOME/.android/adbkey.pub" - # Optional extra keys — must not fail the script under `set -e` when absent. - shopt -s nullglob - for k in /tmp/jtube-adbkeys/*.pub; do cat "$k"; done - shopt -u nullglob + cat /tmp/jtube-adbkeys/*.pub 2>/dev/null } > "$VKEYS" chmod 600 "$VKEYS" echo "Booting AVD '$AVD' …" -ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" "${ACCEL[@]:-}" \ +ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & echo "emulator PID $! — log: /tmp/jtube-emu.log" for i in $(seq 1 90); do - # adb exits non-zero while the device is still booting — don't let - # pipefail+set -e kill the wait loop. - ok=$( ("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null || true) | tr -d '\r') - if [ "$ok" = "1" ]; then echo "BOOTED"; break; fi + ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } sleep 5 done