diff --git a/BACKLOG.md b/BACKLOG.md index f9e6182..4b29b20 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,6 +13,14 @@ 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 8922c39..496ca1f 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -69,45 +69,47 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // 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 - } + // 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() } 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 91d3777..4507861 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,6 +165,15 @@ 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 e6e542f..8bc14bd 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,10 @@ 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 @@ -30,8 +34,19 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit val loggingIn = MutableStateFlow(false) init { - _sessionState.value = - if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup + 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() } } fun login(baseUrl: String, username: String, password: String) { @@ -43,6 +58,7 @@ 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" @@ -67,6 +83,7 @@ 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 a723e8e..dca398c 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,9 +74,7 @@ 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 1cd6eea..74cf99a 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 = { }, + onLeftAtStart = onOpenDrawer, ) } } 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 a6f5aad..cd1c618 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,10 +12,13 @@ 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 @@ -36,14 +39,16 @@ 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 @@ -51,6 +56,8 @@ 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 @@ -67,23 +74,28 @@ enum class Destination(val title: String) { } /** - * 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. + * 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. */ @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) } - val searchFocus = remember { FocusRequester() } + + /** Close the drawer; focus falls back into the content pane. */ + val dismissDrawer = { drawerOpen = false } // 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). @@ -100,8 +112,7 @@ 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) { - drawerOpen = false - runCatching { searchFocus.requestFocus() } + dismissDrawer() } BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { channelDetails = null @@ -133,35 +144,48 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi playingVideo == null ) { when { - drawerOpen -> { drawerOpen = false; true } + drawerOpen -> { dismissDrawer(); true } channelDetails != null -> { channelDetails = null; true } else -> false } } else false }, ) { - androidx.compose.runtime.key(epoch) { Column { - // SmartTube-style top bar: red round search + avatar opening the menu. + // Persistent top bar: logo/title left, account button right. TopBar( - onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, - searchFocus = searchFocus, + profile = userProfile, ) - Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 8.dp), - ) { - when (destination) { + 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) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + // UP over the first row opens the drawer. + upToTopBar = { drawerOpen = true }, ) Destination.MyVideos -> VideoListScreen( @@ -170,7 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -184,7 +208,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.Subscriptions -> VideoListScreen( @@ -193,7 +217,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) Destination.History -> VideoListScreen( @@ -202,31 +226,38 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, + upToTopBar = { drawerOpen = true }, ) + } + } + } + + // 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 + }, ) } } @@ -242,120 +273,109 @@ private fun Destination.icon(): ImageVector = Destination.History -> Icons.Filled.History } -/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +/** + * 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. + */ @Composable private fun TopBar( - onSearchClick: () -> Unit, onAccountClick: () -> Unit, - searchFocus: FocusRequester, + profile: PeerTubeRepository.UserProfile?, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 10.dp), - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - RoundTopButton( - bg = Color(0xFFCE2B2B), - icon = Icons.Filled.Search, - desc = "Search", - onClick = onSearchClick, - focusRequester = searchFocus, - ) - // "Who is logged in" — account button: avatar circle + display name (SmartTube). - val accountFocused = remember { mutableStateOf(false) } - Row( + // 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 - .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), + .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, + ) + 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. + Row( + modifier = Modifier.clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.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), - ) - } - } + AccountAvatar( + url = profile?.avatarUrl ?: AppGraph.tokenStore.userAvatarUrl, + size = 40.dp, + ) Text( - text = AppGraph.tokenStore.userDisplayName - ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", - color = Color(0xFFB0B0B0), + text = profile?.displayName + ?: AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') + ?: "Account", + color = Color(0xFFD0D0D0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(end = 8.dp), + modifier = Modifier + .padding(end = 8.dp) + // keep the button compact; the bar never wraps under a long name + .widthIn(max = 160.dp), + maxLines = 1, ) } - 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 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, +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), + ) + } } } @@ -381,7 +401,9 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp) + // 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) .onPreviewKeyEvent { ke -> // RIGHT from the drawer closes it; focus returns to the top bar. if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && @@ -392,12 +414,6 @@ 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 8254ee1..b40baf9 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,6 +11,7 @@ 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 @@ -25,6 +26,7 @@ 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 @@ -45,12 +47,15 @@ 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 @@ -172,6 +177,9 @@ 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), @@ -189,7 +197,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) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel) } } @@ -251,16 +259,19 @@ 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) } - var playerViewRef by remember { mutableStateOf(null) } + // 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) } // 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 paused controls (#4). - val playPauseFocus = remember { FocusRequester() } + // Overlay focus/position state for the controls (#4). val seekBarFocus = remember { FocusRequester() } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } @@ -290,12 +301,12 @@ private fun PlayerSurface( .build() } - // Pause: move focus into the overlay (the Play button, like SmartTube); resume: + // Pause: move focus into the overlay (the seek bar — the only control); resume: // back to the plain key handler so the next OK pauses again. LaunchedEffect(paused) { if (paused) { positionMs.longValue = player.currentPosition - runCatching { playPauseFocus.requestFocus() } + runCatching { seekBarFocus.requestFocus() } } else { runCatching { keyFocus.requestFocus() } } @@ -349,11 +360,12 @@ private fun PlayerSurface( player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { paused = !isPlaying - if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar + } + + override fun onPlaybackStateChanged(playbackState: Int) { + buffering = playbackState != Player.STATE_READY } }) - // 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() @@ -390,7 +402,10 @@ private fun PlayerSurface( onBack() true } - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + 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 -> { player.playWhenReady = !player.playWhenReady true } @@ -407,21 +422,38 @@ 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 { - paused && (code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE) -> { + // BACK/Esc leave the player in any state, from any focus. + code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { onBack(); true } - // While paused the overlay handles arrows + CENTER (#4); unhandled - // keys must not wake the native bar either. + // While paused our overlay has focus and handles everything. paused -> false + // Transient controls hold focus while playing: let them move it. + controlsVisible && !surfaceFocused -> false else -> { val handled = handleKey(ke.nativeKeyEvent) if (!handled) { @@ -430,15 +462,17 @@ private fun PlayerSurface( ) { consumedDownKey = 0 // trailing UP of a handled press } else { - // UP/DOWN etc. reveal the controls briefly - playerViewRef?.showController() + // UP/DOWN etc. reveal OUR controls, focus the Play button. + controlsVisible = true + if (surfaceFocused) { + runCatching { seekBarFocus.requestFocus() } + } } true } else { if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { consumedDownKey = ke.nativeKeyEvent.keyCode } - if (player.playWhenReady) playerViewRef?.hideController() true } } @@ -452,27 +486,41 @@ private fun PlayerSurface( // screen regardless of the letterbox geometry (issue #1). (LayoutInflater.from(ctx) .inflate(R.layout.player_video_view, null) as PlayerView).apply { - useController = true + // Native controller must never appear (issue: hovering with the + // mouse showed the old play UI) — we own the controls entirely. + useController = false 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 - playerViewRef = this + // A click (mouse) reveals our overlay instead. + setOnClickListener { controlsVisible = true } } }, modifier = Modifier.fillMaxSize(), ) - // Paused → SmartTube-style controls: seek bar over transport buttons over info. - if (paused) { + // 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) { PausedControls( detail = s.detail, player = player, positionMs = positionMs, - playPauseFocus = playPauseFocus, + playing = !paused, seekBarFocus = seekBarFocus, + onOpenChannel = onOpenChannel, modifier = Modifier.align(Alignment.BottomCenter), ) } @@ -492,257 +540,199 @@ private fun seekOverlay( } /** - * 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. + * 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). */ @Composable private fun PausedControls( detail: VideoDetailDto, player: ExoPlayer, positionMs: androidx.compose.runtime.MutableLongState, - playPauseFocus: FocusRequester, + // True while play runs (transient overlay): keeps the times ticking; + // false = persistent paused overlay. + playing: Boolean, 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(Color(0xDC000000)) - .padding(horizontal = 24.dp, vertical = 18.dp), + .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), ) { + // 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 starts playback. */ +/** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles 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 - 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 + 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 } - } - .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), - ) + 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 } } - } - 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( + .focusable() + .padding(horizontal = 4.dp), + ) { + // Thin SmartTube-style track: rounded, red progress, white round knob. + Canvas( modifier = Modifier - .weight(1f) - .padding(start = 16.dp), + .align(Alignment.CenterStart) + .fillMaxWidth() + .height(if (barFocused) 12.dp else 6.dp) + .clip(RoundedCornerShape(7.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, - ) + 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), + ) + } } - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatDuration((positionMs / 1000).toInt()) + " · " + formatDuration(detail.duration), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, + // 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 = "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) @@ -751,23 +741,23 @@ private fun formatViews(views: Long): String = else -> "$views views" } -private fun formatAge(iso: String?): String { +/** Absolute published date + time in the device locale (relative age was too coarse). */ +private fun formatPublished(iso: String?): String { if (iso.isNullOrBlank()) return "" return try { - val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) - val days = age.toDays() - when { - days >= 365 -> "${days / 365}y ago" - days >= 30 -> "${days / 30}mo ago" - days >= 1 -> "${days}d ago" - age.toHours() >= 1 -> "${age.toHours()}h ago" - else -> "just now" - } + 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()) + ) } 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 02e43b3..17d7681 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,9 +2,12 @@ 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 @@ -15,21 +18,24 @@ 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 @@ -80,9 +86,19 @@ fun VideoListScreen( key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } - when (source) { - is ListSource.Search -> SearchHeader(viewModel) - else -> {} + 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 + } } if (viewModel.loadingInitial && items.isEmpty()) { @@ -127,6 +143,15 @@ 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 @@ -140,7 +165,7 @@ fun VideoListScreen( onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, compact = true, - onLeftAtStart = if (index == 0) onOpenDrawer else null, + onLeftAtStart = if (index % cellsPerRow.coerceAtLeast(1) == 0) onOpenDrawer else null, onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), @@ -194,6 +219,54 @@ 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 48a15d2..5d6b66f 100644 --- a/docs/agent-container.md +++ b/docs/agent-container.md @@ -9,6 +9,12 @@ 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. @@ -46,7 +52,8 @@ 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) and `/tmp/jtube-emu-run.log` (script). +- Logs: `/tmp/jtube-emu.log` (QEMU), `/tmp/jtube-socat.log` (bridge), + `/tmp/jtube-adbkeys-combined` (rebuilt each launch). - 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 deleted file mode 100755 index 5fc8ed2..0000000 --- a/scripts/bridge-emulator.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/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 5995100..fdb9af7 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -62,11 +62,13 @@ if [ "$MODE" = windowed ]; then else echo "JTUBE_EMU_MODE=windowed but no display detected — falling back to headless" WINDOWED=0 - ACCEL+=(-no-window) + ACCEL+=(-no-window -gpu swiftshader_indirect) fi else WINDOWED=0 - ACCEL+=(-no-window) + # 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) echo "headless (default; JTUBE_EMU_MODE=windowed for a window) — no emulator window" fi if [ -e /dev/kvm ]; then @@ -79,21 +81,27 @@ 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" - cat /tmp/jtube-adbkeys/*.pub 2>/dev/null + # 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 } > "$VKEYS" chmod 600 "$VKEYS" echo "Booting AVD '$AVD' …" -ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ +ADB_VENDOR_KEYS="$VKEYS" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" "${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 - ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') - [ "$ok" = "1" ] && { echo "BOOTED"; break; } + # 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 sleep 5 done