From 43056065e136779c11c4dd6cc3d6715725a64a30 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 11:29:08 +0000 Subject: [PATCH] ui: SmartTube-style home rows, focus memory and UX-review fixes The home screen is now stacked horizontal rows ('Continue watching' with red progress bars from server-reported positions, 'New videos' paged) instead of one tall list - matching the SmartTube look the app is modeled on. The player renders as an overlay so the list beneath keeps its scroll and focus across BACK (was resetting to the top), grid scroll state is saved per destination, cards render focus scale/border via tv-material, thumbnails carry relative dates and privacy badges only where meaningful, overscan margins guard the 1080p safe area, and the setup screen got a password reveal, clipboard paste helpers and an always-focusable validating Sign in. UX review by Claude Opus 5.5 (claude CLI) on emulator screenshots; remaining suggestions are triaged in BACKLOG.md. --- BACKLOG.md | 43 ++-- app/build.gradle.kts | 1 + .../java/net/jeena/jtubetv/MainActivity.kt | 21 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 7 +- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 6 + .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 1 + .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 10 +- .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 195 ++++++++++++++++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 122 ++++++----- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 4 + .../jeena/jtubetv/ui/screens/SetupScreen.kt | 63 +++++- .../jtubetv/ui/screens/list/VideoCard.kt | 174 ++++++++++------ .../ui/screens/list/VideoListScreen.kt | 27 ++- 14 files changed, 532 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt diff --git a/BACKLOG.md b/BACKLOG.md index ddd3ea7..947bade 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,27 +2,42 @@ Status vocabulary: `todo` · `doing` · `done`. -## v0.1 — MVP (this release) +## v0.1 — MVP (implemented, pending device sign-off) | # | Item | Status | Notes | |---|------|--------|-------| -| 0 | Ask for credentials + instance URL, store safely | todo | Login screen → `users/token`; store tokens + OAuth client creds in EncryptedSharedPreferences, never the password; refresh-token single-flight on 401 | -| 1 | List local videos (public) + user's own videos incl. private/internal | todo | Sidebar entries "Videos" (public, `videos?isLocal=true`) and "My videos" (`users/me/videos`); privacy badge on non-public items | -| 2 | List local channels | todo | `video-channels?search=&searchTarget=local`; tap → channel videos | -| 3 | Search local videos | todo | `videos?search=…&isLocal=true` | -| 4 | Subscription videos | todo | `users/me/subscriptions/videos` | -| 5 | Watch history (server) | todo | `users/me/history/videos` read-only | -| 6 | Playback + report to server history | todo | ExoPlayer HLS (mp4 fallback); `POST videos/{shortUuid}/watching` every ~20 s | +| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation | +| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too | +| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 | +| 3 | Search local videos | done | Own screen in sidebar | +| 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 | ## Next up (v0.2+) +UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll +memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus +(tv-material Cards with scale/border), relative dates on cards, privacy badge only on +My videos, password reveal + paste, overscan margins, sign-out entry, neutral form +focus colors, always-focusable Sign in with inline validation. Deferred from review: + +- "Continue watching" refinement: auto-remove finished entries, exact resume (start + playback at `userHistory.currentTime`, show "Resuming at 1:23" toast). +- Trending/featured row on Home; rows per subscribed channel (SmartTube-like). +- Video progress bars in ALL lists (needs per-video history merge; currently only + Continue-watching has them). +- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed; + auto-next after video end; subtitles (captions) support; phone-QR-assisted login. +- Drawer restructure proposal: Home / Channels (subs first) / Search / History / + Account — decide together with merged-feed item below. - Unified "accessible videos" feed: merge public-local + own private into one paged, - deduplicated list (v0.1 keeps them as two sidebar entries). -- Logout / "switch account" button on the Home screen. -- Playback polish: resume from server-recorded position via - `GET videos/{shortUuid}?userHistory=true` (verified working, returns - `userHistory.currentTime`), next/previous video from list, focus current item in - history screen. + deduplicated list (v0.1 keeps Home rows + My videos). +- Global local-channels browse (search endpoint rejects empty queries) — consider + `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`. +- Logout / switch account: sign-out entry exists; add switch-account (re-login screen + without losing scroll state). +- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. - Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: instance thumbnail paths are publicly readable (HTTP 200 without auth); add a fallback poster only if other instances behave differently. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b0be9a..8778cad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt index 34e1fba..669b5bf 100644 --- a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt +++ b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt @@ -3,6 +3,7 @@ package net.jeena.jtubetv import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator @@ -12,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import net.jeena.jtubetv.BuildConfig import androidx.lifecycle.viewmodel.compose.viewModel import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.SessionState @@ -22,16 +24,31 @@ import net.jeena.jtubetv.ui.theme.JTubeTvTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val viewModel: AppViewModel by viewModels() + seedFromIntent(viewModel) setContent { JTubeTvTheme { - AppRoot() + AppRoot(viewModel) } } } + + /** + * Debug-only automation hook: `am start ... --es jtube_url ... --es jtube_user ... + * --es jtube_pass ...` performs the login without IME gymnastics. Never available + * in release builds. + */ + private fun seedFromIntent(viewModel: AppViewModel) { + if (!BuildConfig.DEBUG) return + val url = intent.getStringExtra("jtube_url") ?: return + val user = intent.getStringExtra("jtube_user") ?: return + val pass = intent.getStringExtra("jtube_pass") ?: return + viewModel.login(url, user, pass) + } } @Composable -private fun AppRoot(viewModel: AppViewModel = viewModel()) { +private fun AppRoot(viewModel: AppViewModel) { val session by viewModel.sessionState.collectAsState() Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) { 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 ec2ffb3..cee4fe0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -110,8 +110,8 @@ class PeerTubeRepository( // ---- playback ---------------------------------------------------------- - suspend fun video(shortUuid: String): Result = - runCatching { network.api.video(shortUuid) } + suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result = + runCatching { network.api.video(shortUuid, userHistory = userHistory) } suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } @@ -146,6 +146,9 @@ class PeerTubeRepository( fun hasSession(): Boolean = store.hasSession() + /** Full logout (feature request from UX review): forget URL, username and tokens. */ + fun signOut() = store.clearAll() + class BadCredentialsException : Exception("Invalid credentials") private fun plainOkHttp(): OkHttpClient = 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 f335486..52a1de0 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 @@ -91,6 +91,11 @@ data class StreamingPlaylist( val files: List = emptyList(), ) +@Serializable +data class UserHistoryDto( + val currentTime: Int? = null, +) + @Serializable data class VideoDetailDto( val id: Long, @@ -107,6 +112,7 @@ data class VideoDetailDto( val channel: VideoChannelSummary, val files: List = emptyList(), val streamingPlaylists: List = emptyList(), + val userHistory: UserHistoryDto? = null, ) @Serializable diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 5b24be4..76dd8e2 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -95,6 +95,7 @@ interface PeerTubeApi { @GET("videos/{shortUuid}") suspend fun video( @Path("shortUuid") shortUuid: String, + @Query("userHistory") userHistory: Boolean? = null, ): VideoDetailDto @POST("videos/{shortUuid}/watching") 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 03721c3..b615684 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -30,9 +30,9 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit fun login(baseUrl: String, username: String, password: String) { viewModelScope.launch { loggingIn.value = true - loginError.value = null + loginError.value = null val result = repository.login(baseUrl, username, password) - loggingIn.value = false + loggingIn.value = false if (result.isSuccess) { _sessionState.value = SessionState.Ready } else { @@ -44,4 +44,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit } } } + + /** Clears the stored session; the user returns to the setup screen. */ + fun logout() { + repository.signOut() + _sessionState.value = SessionState.NeedsSetup + } } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index afe4a03..c0a4d3f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -132,6 +132,7 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), + colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { val avatar = channel.avatars.maxByOrNull { it.width } 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 new file mode 100644 index 0000000..91d4d8a --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -0,0 +1,195 @@ +package net.jeena.jtubetv.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Text as TvText +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.PeerTubeRepository +import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.list.VideoListViewModel +import net.jeena.jtubetv.ui.screens.list.VideoCard + +private const val ROW_CARD_WIDTH = 296 + +/** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ +data class ContinueItem(val video: VideoDto, val progress: Float?) + +class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { + + var continueItems by mutableStateOf>(emptyList()) + private set + var continueLoading by mutableStateOf(true) + private set + private var loaded = false + + val recent = RecentVideosViewModel(repository) + + /** History's newest entries + their resume positions from video detail */ + fun load() { + if (loaded) return + loaded = true + viewModelScope.launch { + val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } + if (history == null) { + continueLoading = false + return@launch + } + // resume info needs one detail call with ?userHistory=true per entry; + // keep it small - only the first six videos. + val items = history.take(6).map { entry -> + async { + val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() + val watched = detail?.userHistory?.currentTime ?: 0 + val progress = if (detail != null && detail.duration > 0 && watched > 0) { + (watched.toFloat() / detail.duration).coerceIn(0.001f, 0.99f) + } else null + ContinueItem(entry.video, progress) + } + }.awaitAll() + continueItems = items + continueLoading = false + } + } + + companion object { + val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + HomeRowsViewModel(AppGraph.repository) as T + } + } +} + +/** Paged "New videos" row content. */ +class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + override suspend fun fetch(start: Long, count: Int): Result> = + repository.localVideos(start, count).map { it.data } + + companion object { + fun factory(repo: PeerTubeRepository): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RecentVideosViewModel(repo) as T + } + } +} + +/** + * SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress + * bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally. + */ +@Composable +fun HomeRowsScreen( + onOpenVideo: (VideoDto) -> Unit, + viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), +) { + LaunchedEffect(Unit) { viewModel.load() } + val recentItems = viewModel.recent.items + + Column(modifier = Modifier.fillMaxSize()) { + if (viewModel.continueItems.isNotEmpty()) { + VideoRow( + header = "Continue watching", + videos = viewModel.continueItems.map { it.video }, + progressOf = { shortUUID -> + viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress + }, + onOpenVideo = onOpenVideo, + // already limited to 6; no paging + onNearEnd = { }, + ) + } + VideoRow( + header = "New videos", + videos = recentItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onNearEnd = { viewModel.recent.loadMore() }, + ) + } +} + +@Composable +private fun VideoRow( + header: String, + videos: List, + progressOf: (String) -> Float?, + onOpenVideo: (VideoDto) -> Unit, + onNearEnd: () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + TvText( + text = header, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + ) + if (videos.isEmpty()) { + LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows + Box( + Modifier + .fillMaxWidth() + .height(64.dp), + contentAlignment = Alignment.CenterStart, + ) { + TvText( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + modifier = Modifier.padding(start = 16.dp), + ) + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(videos) { index, video -> + VideoCard( + video = video, + onClick = { onOpenVideo(video) }, + modifier = Modifier.width(ROW_CARD_WIDTH.dp), + showPrivacy = false, + progress = progressOf(video.shortUUID), + ) + if (index >= videos.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { onNearEnd() } + } + } + } + } + } + } +} \ No newline at end of file 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 537bbe1..900a3f7 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.VideoLibrary @@ -24,11 +25,13 @@ import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import androidx.activity.compose.BackHandler enum class Destination(val title: String) { - LocalVideos("Videos"), + Home("Home"), MyVideos("My videos"), Channels("Channels"), Search("Search"), @@ -36,61 +39,76 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. */ +/** Home = sidebar (D-pad navigable) + content pane. The player renders as a + * fullscreen overlay: the list beneath stays composed, so its scroll position and + * focused card survive BACK (couch users don't want to re-navigate from the top). */ @Composable -fun HomeScreen() { - var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) } +fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { + var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID - if (playingVideo != null) { - PlayerScreen( - shortUuid = playingVideo!!, - onBack = { playingVideo = null }, - ) - return + // Channel drilldown hides the sidebar; remote BACK must return to the channels list. + BackHandler(enabled = playingVideo == null && channelDetails != null) { + channelDetails = null } - if (destination == Destination.Channels && channelDetails != null) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - ) - return - } - - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, + Box(modifier = Modifier.fillMaxSize()) { + if (destination == Destination.Channels && channelDetails != null) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins (list content, not the player). + .padding(horizontal = 32.dp), + ) { + VideoListScreen( + source = ListSource.Channel(channelDetails!!, channelDetails!!), + onOpenVideo = { playingVideo = it.shortUUID }, ) } - } - }) { - Row(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (destination) { - Destination.LocalVideos -> - VideoListScreen( - source = ListSource.LocalVideos, - onOpenVideo = { playingVideo = it.shortUUID }, + } else { + NavigationDrawer(drawerContent = { + androidx.compose.foundation.layout.Column( + modifier = Modifier.width(220.dp).padding(vertical = 16.dp), + ) { + Text( + text = "JTube TV", + modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), + style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, + color = androidx.compose.ui.graphics.Color.White, + ) + Destination.entries.forEach { dest -> + NavigationDrawerItem( + selected = dest == destination, + onClick = { + destination = dest + channelDetails = null + }, + leadingContent = { + Icon(imageVector = dest.icon(), contentDescription = null) + }, + content = { Text(dest.title) }, ) + } + NavigationDrawerItem( + selected = false, + onClick = { viewModel.logout() }, + leadingContent = { + Icon(imageVector = Icons.Filled.Logout, contentDescription = null) + }, + content = { Text("Sign out") }, + ) + } + }) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 16.dp), + ) { + when (destination) { + Destination.Home -> + HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, @@ -115,14 +133,22 @@ fun HomeScreen() { onOpenVideo = { playingVideo = it.shortUUID }, ) } + } } } + + if (playingVideo != null) { + PlayerScreen( + shortUuid = playingVideo!!, + onBack = { playingVideo = null }, + ) + } } } private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = when (this) { - Destination.LocalVideos -> Icons.Filled.VideoLibrary + Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo Destination.Channels -> Icons.AutoMirrored.Filled.List Destination.Search -> Icons.Filled.Search 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 5976dc9..b3f8cbf 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 @@ -177,10 +177,14 @@ private fun PlayerSurface( if (player.isPlaying) { val now = System.currentTimeMillis() val position = player.currentPosition + android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms") if (position >= 5_000 && now - lastReported >= 20_000) { + android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s") viewModel.reportWatching(shortUuid, (position / 1000).toInt()) lastReported = now } + } else { + android.util.Log.d("JTubeWatch", "tick not playing") } android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt index 52e15bb..b0f2614 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.compose.foundation.layout.Arrangement 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.padding @@ -18,6 +19,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @@ -34,8 +36,11 @@ fun SetupScreen( val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") } val username = rememberSaveable { mutableStateOf("") } val password = rememberSaveable { mutableStateOf("") } + val showPassword = rememberSaveable { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current val busy = loggingIn.collectAsState() val message = error.collectAsState() + val localError = rememberSaveable { mutableStateOf(null) } Column( modifier = Modifier @@ -59,6 +64,7 @@ fun SetupScreen( label = { Text("PeerTube instance URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = username.value, @@ -66,21 +72,60 @@ fun SetupScreen( label = { Text("Username") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = password.value, onValueChange = { password.value = it }, label = { Text("Password") }, singleLine = true, + visualTransformation = + if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) - if (message.value != null) { - Text(message.value!!, color = MaterialTheme.colorScheme.error) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.TextButton(onClick = { showPassword.value = !showPassword.value }) { + Text(if (showPassword.value) "Hide password" else "Show password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + password.value = it + } + }, + ) { + Text("Paste password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + username.value = it + } + }, + ) { + Text("Paste username") + } + } + if (message.value != null || localError.value != null) { + Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error) } Button( - onClick = { onLogin(url.value, username.value, password.value) }, - enabled = !busy.value && url.value.isNotBlank() && - username.value.isNotBlank() && password.value.isNotBlank(), + // Deliberately always focusable: on TV a permanently disabled button + // is unreachable and leaves the user stuck without feedback (UX review). + // Validation happens on press with an inline message instead. + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + enabled = !busy.value, modifier = Modifier.fillMaxWidth(), ) { if (busy.value) { @@ -92,3 +137,11 @@ fun SetupScreen( } } } + +/** Neutral focus colors; the red primary reads as "error" on form fields (UX review). */ +@Composable +private fun fieldColors() = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedBorderColor = androidx.compose.ui.graphics.Color.White, + focusedLabelColor = androidx.compose.ui.graphics.Color.White, + unfocusedLabelColor = androidx.compose.ui.graphics.Color(0xFFB8B8B8), +) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index 75fc84c..5eaa1c1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -8,11 +8,7 @@ import androidx.compose.foundation.layout.Row 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.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,80 +19,110 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto +import java.time.Duration +import java.time.Instant +import java.time.format.DateTimeParseException import java.util.Locale -/** Horizontal card: thumbnail left, title/channel/privacy right. Focused via D-pad. */ +/** + * Grid card: 16:9 thumbnail on top, title/meta underneath. tv-material Card gives the + * D-pad focus treatment (scale + 3dp border) for free. + * + * [showPrivacy] is on for "My videos" only — on a personal instance the Internal + * badge on every card is noise (UX review finding). + */ @Composable fun VideoCard( video: VideoDto, onClick: () -> Unit, modifier: Modifier = Modifier, + showPrivacy: Boolean = true, + progress: Float? = null, ) { Card( onClick = onClick, modifier = modifier .fillMaxWidth() - .height(132.dp) - .padding(horizontal = 8.dp, vertical = 4.dp), - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + .height(268.dp) + .padding(6.dp), + colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), ) { - Row(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .size(width = 234.dp, height = 132.dp) - .background(Color(0xFF222222)), - ) { - video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> - AsyncImage( - model = AppGraph.repository.absoluteUrl(path), - contentDescription = video.name, - contentScale = ContentScale.Crop, + Box( + modifier = Modifier + .fillMaxWidth() + .height(158.dp) + .background(Color(0xFF222222)), + ) { + video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> + AsyncImage( + model = AppGraph.repository.absoluteUrl(path), + contentDescription = video.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(158.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + if (video.duration > 0) { + Text( + text = formatDuration(video.duration), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp) + .background(Color(0xCC000000), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + progress?.takeIf { it in 0.001f..0.999f }?.let { fraction -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.BottomStart) + .background(Color(0x55FFFFFF)), + ) { + Box( modifier = Modifier - .size(width = 234.dp, height = 132.dp) - .clip(RoundedCornerShape(8.dp)), - ) - } - if (video.duration > 0) { - Text( - text = formatDuration(video.duration), - style = MaterialTheme.typography.labelSmall, - color = Color.White, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(6.dp) - .background(Color(0xCC000000), RoundedCornerShape(4.dp)) - .padding(horizontal = 6.dp, vertical = 2.dp), + .fillMaxWidth(fraction) + .height(4.dp) + .background(Color(0xFFCE2B2B)), ) } } - Column( - modifier = Modifier - .padding(12.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + } + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = video.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = video.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, + text = video.channel.displayName.ifBlank { video.channel.name }, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFB8B8B8), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = video.channel.displayName.ifBlank { video.channel.name }, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFB8B8B8), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - video.privacy?.takeIf { it.id != 1 }?.let { privacy -> + video.privacy?.takeIf { it.id != 1 }?.let { privacy -> + if (showPrivacy) { Text( text = privacy.label ?: "non-public", style = MaterialTheme.typography.labelSmall, @@ -106,22 +132,21 @@ fun VideoCard( .padding(horizontal = 6.dp, vertical = 2.dp), ) } - if (video.nsfw) { - Text( - text = "NSFW", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFFE2A57C), - ) - } } - if (video.views >= 0) { + if (video.nsfw) { Text( - text = formatViews(video.views), - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + text = "NSFW", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFE2A57C), ) } } + Text( + text = formatAge(video.publishedAt) + + if (video.views > 0) " · " + formatViews(video.views) else "", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + ) } } } @@ -142,4 +167,21 @@ internal fun formatViews(views: Long): String = views >= 1_000 -> "%.1fK views".format(Locale.US, views / 1_000.0) views == 1L -> "1 view" else -> "$views views" - } \ No newline at end of file + } + +internal fun formatAge(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return try { + val age = Duration.between(Instant.parse(iso), Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now") + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } + } catch (_: DateTimeParseException) { + "" + } +} \ No newline at end of file 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 b6f278f..3bb45e0 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 @@ -5,8 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -14,12 +15,15 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +44,15 @@ fun VideoListScreen( ), ) { val items = viewModel.items + // Pull initial focus out of the sidebar rail and into the content grid. + val initialFocus = remember { FocusRequester() } + LaunchedEffect(items.isNotEmpty(), source) { + if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + } + // Scroll position survives destination switches (per source), not just player exits. + val gridState = androidx.compose.runtime.saveable.rememberSaveable( + key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + ) { androidx.compose.foundation.lazy.grid.LazyGridState() } when (source) { is ListSource.Search -> SearchHeader(viewModel) @@ -82,8 +95,12 @@ fun VideoListScreen( return } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, item -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + ) { + itemsIndexed(items) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.Video -> item.video @@ -92,6 +109,8 @@ fun VideoListScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, + showPrivacy = source is ListSource.MyVideos, + modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() }