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() }