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.
This commit is contained in:
Jeena 2026-10-10 11:29:08 +00:00
parent 83dde8ba74
commit 43056065e1
14 changed files with 532 additions and 143 deletions

View file

@ -2,27 +2,42 @@
Status vocabulary: `todo` · `doing` · `done`. Status vocabulary: `todo` · `doing` · `done`.
## v0.1 — MVP (this release) ## v0.1 — MVP (implemented, pending device sign-off)
| # | Item | Status | Notes | | # | 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 | | 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 | 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 | | 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 | List local channels | todo | `video-channels?search=&searchTarget=local`; tap → channel videos | | 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 | todo | `videos?search=…&isLocal=true` | | 3 | Search local videos | done | Own screen in sidebar |
| 4 | Subscription videos | todo | `users/me/subscriptions/videos` | | 4 | Subscription videos | done | Verified incl. remote (federated) channels |
| 5 | Watch history (server) | todo | `users/me/history/videos` read-only | | 5 | Watch history (server) | done | read-only grid |
| 6 | Playback + report to server history | todo | ExoPlayer HLS (mp4 fallback); `POST videos/{shortUuid}/watching` every ~20 s | | 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+) ## 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, - Unified "accessible videos" feed: merge public-local + own private into one paged,
deduplicated list (v0.1 keeps them as two sidebar entries). deduplicated list (v0.1 keeps Home rows + My videos).
- Logout / "switch account" button on the Home screen. - Global local-channels browse (search endpoint rejects empty queries) — consider
- Playback polish: resume from server-recorded position via `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`.
`GET videos/{shortUuid}?userHistory=true` (verified working, returns - Logout / switch account: sign-out entry exists; add switch-account (re-login screen
`userHistory.currentTime`), next/previous video from list, focus current item in without losing scroll state).
history screen. - Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning.
- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: - Thumbnails of private/internal videos may 404 — verified on tube.jeena.net:
instance thumbnail paths are publicly readable (HTTP 200 without auth); add a instance thumbnail paths are publicly readable (HTTP 200 without auth); add a
fallback poster only if other instances behave differently. fallback poster only if other instances behave differently.

View file

@ -35,6 +35,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }

View file

@ -3,6 +3,7 @@ package net.jeena.jtubetv
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -12,6 +13,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import net.jeena.jtubetv.BuildConfig
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.AppViewModel
import net.jeena.jtubetv.ui.SessionState import net.jeena.jtubetv.ui.SessionState
@ -22,16 +24,31 @@ import net.jeena.jtubetv.ui.theme.JTubeTvTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val viewModel: AppViewModel by viewModels()
seedFromIntent(viewModel)
setContent { setContent {
JTubeTvTheme { 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 @Composable
private fun AppRoot(viewModel: AppViewModel = viewModel()) { private fun AppRoot(viewModel: AppViewModel) {
val session by viewModel.sessionState.collectAsState() val session by viewModel.sessionState.collectAsState()
Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) {

View file

@ -110,8 +110,8 @@ class PeerTubeRepository(
// ---- playback ---------------------------------------------------------- // ---- playback ----------------------------------------------------------
suspend fun video(shortUuid: String): Result<VideoDetailDto> = suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid) } runCatching { network.api.video(shortUuid, userHistory = userHistory) }
suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> = suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> =
runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) }
@ -146,6 +146,9 @@ class PeerTubeRepository(
fun hasSession(): Boolean = store.hasSession() 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") class BadCredentialsException : Exception("Invalid credentials")
private fun plainOkHttp(): OkHttpClient = private fun plainOkHttp(): OkHttpClient =

View file

@ -91,6 +91,11 @@ data class StreamingPlaylist(
val files: List<VideoFile> = emptyList(), val files: List<VideoFile> = emptyList(),
) )
@Serializable
data class UserHistoryDto(
val currentTime: Int? = null,
)
@Serializable @Serializable
data class VideoDetailDto( data class VideoDetailDto(
val id: Long, val id: Long,
@ -107,6 +112,7 @@ data class VideoDetailDto(
val channel: VideoChannelSummary, val channel: VideoChannelSummary,
val files: List<VideoFile> = emptyList(), val files: List<VideoFile> = emptyList(),
val streamingPlaylists: List<StreamingPlaylist> = emptyList(), val streamingPlaylists: List<StreamingPlaylist> = emptyList(),
val userHistory: UserHistoryDto? = null,
) )
@Serializable @Serializable

View file

@ -95,6 +95,7 @@ interface PeerTubeApi {
@GET("videos/{shortUuid}") @GET("videos/{shortUuid}")
suspend fun video( suspend fun video(
@Path("shortUuid") shortUuid: String, @Path("shortUuid") shortUuid: String,
@Query("userHistory") userHistory: Boolean? = null,
): VideoDetailDto ): VideoDetailDto
@POST("videos/{shortUuid}/watching") @POST("videos/{shortUuid}/watching")

View file

@ -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
}
} }

View file

@ -132,6 +132,7 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp), .padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }

View file

@ -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<List<ContinueItem>>(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 <T : ViewModel> create(modelClass: Class<T>): T =
HomeRowsViewModel(AppGraph.repository) as T
}
}
}
/** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
repository.localVideos(start, count).map { it.data }
companion object {
fun factory(repo: PeerTubeRepository): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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<VideoDto>,
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() }
}
}
}
}
}
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.History 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.Subscriptions
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VideoLibrary 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.NavigationDrawerItem
import androidx.tv.material3.Text import androidx.tv.material3.Text
import net.jeena.jtubetv.data.api.VideoDto 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.list.ListSource
import net.jeena.jtubetv.ui.screens.list.VideoListScreen import net.jeena.jtubetv.ui.screens.list.VideoListScreen
import androidx.activity.compose.BackHandler
enum class Destination(val title: String) { enum class Destination(val title: String) {
LocalVideos("Videos"), Home("Home"),
MyVideos("My videos"), MyVideos("My videos"),
Channels("Channels"), Channels("Channels"),
Search("Search"), Search("Search"),
@ -36,29 +39,34 @@ enum class Destination(val title: String) {
History("History"), 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 @Composable
fun HomeScreen() { fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) {
var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) } var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
if (playingVideo != null) { // Channel drilldown hides the sidebar; remote BACK must return to the channels list.
PlayerScreen( BackHandler(enabled = playingVideo == null && channelDetails != null) {
shortUuid = playingVideo!!, channelDetails = null
onBack = { playingVideo = null },
)
return
} }
Box(modifier = Modifier.fillMaxSize()) {
if (destination == Destination.Channels && channelDetails != null) { 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( VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelDetails!!), source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
) )
return
} }
} else {
NavigationDrawer(drawerContent = { NavigationDrawer(drawerContent = {
androidx.compose.foundation.layout.Column( androidx.compose.foundation.layout.Column(
modifier = Modifier.width(220.dp).padding(vertical = 16.dp), modifier = Modifier.width(220.dp).padding(vertical = 16.dp),
@ -67,6 +75,7 @@ fun HomeScreen() {
text = "JTube TV", text = "JTube TV",
modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), modifier = Modifier.padding(start = 28.dp, bottom = 16.dp),
style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, style = androidx.tv.material3.MaterialTheme.typography.headlineSmall,
color = androidx.compose.ui.graphics.Color.White,
) )
Destination.entries.forEach { dest -> Destination.entries.forEach { dest ->
NavigationDrawerItem( NavigationDrawerItem(
@ -81,16 +90,25 @@ fun HomeScreen() {
content = { Text(dest.title) }, content = { Text(dest.title) },
) )
} }
NavigationDrawerItem(
selected = false,
onClick = { viewModel.logout() },
leadingContent = {
Icon(imageVector = Icons.Filled.Logout, contentDescription = null)
},
content = { Text("Sign out") },
)
} }
}) { }) {
Row(modifier = Modifier.fillMaxSize()) { androidx.compose.foundation.layout.Box(
Box(modifier = Modifier.fillMaxSize()) { modifier = Modifier
.fillMaxSize()
// TV overscan safety margins on the content pane.
.padding(start = 8.dp, end = 32.dp, top = 16.dp),
) {
when (destination) { when (destination) {
Destination.LocalVideos -> Destination.Home ->
VideoListScreen( HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID })
source = ListSource.LocalVideos,
onOpenVideo = { playingVideo = it.shortUUID },
)
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
source = ListSource.MyVideos, source = ListSource.MyVideos,
@ -118,11 +136,19 @@ fun HomeScreen() {
} }
} }
} }
if (playingVideo != null) {
PlayerScreen(
shortUuid = playingVideo!!,
onBack = { playingVideo = null },
)
}
}
} }
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector =
when (this) { when (this) {
Destination.LocalVideos -> Icons.Filled.VideoLibrary Destination.Home -> Icons.Filled.VideoLibrary
Destination.MyVideos -> Icons.Filled.PersonalVideo Destination.MyVideos -> Icons.Filled.PersonalVideo
Destination.Channels -> Icons.AutoMirrored.Filled.List Destination.Channels -> Icons.AutoMirrored.Filled.List
Destination.Search -> Icons.Filled.Search Destination.Search -> Icons.Filled.Search

View file

@ -177,10 +177,14 @@ private fun PlayerSurface(
if (player.isPlaying) { if (player.isPlaying) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val position = player.currentPosition 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) { if (position >= 5_000 && now - lastReported >= 20_000) {
android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s")
viewModel.reportWatching(shortUuid, (position / 1000).toInt()) viewModel.reportWatching(shortUuid, (position / 1000).toInt())
lastReported = now lastReported = now
} }
} else {
android.util.Log.d("JTubeWatch", "tick not playing")
} }
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000)
} }

View file

@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -18,6 +19,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -34,8 +36,11 @@ fun SetupScreen(
val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") } val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") }
val username = rememberSaveable { mutableStateOf("") } val username = rememberSaveable { mutableStateOf("") }
val password = rememberSaveable { mutableStateOf("") } val password = rememberSaveable { mutableStateOf("") }
val showPassword = rememberSaveable { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val busy = loggingIn.collectAsState() val busy = loggingIn.collectAsState()
val message = error.collectAsState() val message = error.collectAsState()
val localError = rememberSaveable { mutableStateOf<String?>(null) }
Column( Column(
modifier = Modifier modifier = Modifier
@ -59,6 +64,7 @@ fun SetupScreen(
label = { Text("PeerTube instance URL") }, label = { Text("PeerTube instance URL") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
) )
TextField( TextField(
value = username.value, value = username.value,
@ -66,21 +72,60 @@ fun SetupScreen(
label = { Text("Username") }, label = { Text("Username") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
) )
TextField( TextField(
value = password.value, value = password.value,
onValueChange = { password.value = it }, onValueChange = { password.value = it },
label = { Text("Password") }, label = { Text("Password") },
singleLine = true, singleLine = true,
visualTransformation =
if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None
else androidx.compose.ui.text.input.PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
) )
if (message.value != null) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message.value!!, color = MaterialTheme.colorScheme.error) 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( Button(
onClick = { onLogin(url.value, username.value, password.value) }, // Deliberately always focusable: on TV a permanently disabled button
enabled = !busy.value && url.value.isNotBlank() && // is unreachable and leaves the user stuck without feedback (UX review).
username.value.isNotBlank() && password.value.isNotBlank(), // 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(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (busy.value) { 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),
)

View file

@ -8,11 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,31 +19,43 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage import coil.compose.AsyncImage
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeParseException
import java.util.Locale 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 @Composable
fun VideoCard( fun VideoCard(
video: VideoDto, video: VideoDto,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showPrivacy: Boolean = true,
progress: Float? = null,
) { ) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(132.dp) .height(268.dp)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(6.dp),
shape = RoundedCornerShape(8.dp), colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) { ) {
Row(modifier = Modifier.fillMaxWidth()) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 234.dp, height = 132.dp) .fillMaxWidth()
.height(158.dp)
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
@ -56,7 +64,8 @@ fun VideoCard(
contentDescription = video.name, contentDescription = video.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(width = 234.dp, height = 132.dp) .fillMaxWidth()
.height(158.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) )
} }
@ -72,21 +81,36 @@ fun VideoCard(
.padding(horizontal = 6.dp, vertical = 2.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
.fillMaxWidth(fraction)
.height(4.dp)
.background(Color(0xFFCE2B2B)),
)
}
}
} }
Column( Column(
modifier = Modifier modifier = Modifier.padding(10.dp),
.padding(12.dp) verticalArrangement = Arrangement.spacedBy(2.dp),
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = video.name, text = video.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
maxLines = 2, maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@ -95,8 +119,10 @@ fun VideoCard(
color = Color(0xFFB8B8B8), color = Color(0xFFB8B8B8),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
video.privacy?.takeIf { it.id != 1 }?.let { privacy -> video.privacy?.takeIf { it.id != 1 }?.let { privacy ->
if (showPrivacy) {
Text( Text(
text = privacy.label ?: "non-public", text = privacy.label ?: "non-public",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@ -106,6 +132,7 @@ fun VideoCard(
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp),
) )
} }
}
if (video.nsfw) { if (video.nsfw) {
Text( Text(
text = "NSFW", text = "NSFW",
@ -114,16 +141,14 @@ fun VideoCard(
) )
} }
} }
if (video.views >= 0) {
Text( Text(
text = formatViews(video.views), text = formatAge(video.publishedAt) +
if (video.views > 0) " · " + formatViews(video.views) else "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A), color = Color(0xFF8A8A8A),
) )
} }
} }
}
}
} }
internal fun formatDuration(seconds: Int): String { internal fun formatDuration(seconds: Int): String {
@ -143,3 +168,20 @@ internal fun formatViews(views: Long): String =
views == 1L -> "1 view" views == 1L -> "1 view"
else -> "$views views" else -> "$views views"
} }
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) {
""
}
}

View file

@ -5,8 +5,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -14,12 +15,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -40,6 +44,15 @@ fun VideoListScreen(
), ),
) { ) {
val items = viewModel.items 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) { when (source) {
is ListSource.Search -> SearchHeader(viewModel) is ListSource.Search -> SearchHeader(viewModel)
@ -82,8 +95,12 @@ fun VideoListScreen(
return return
} }
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { LazyVerticalGrid(
itemsIndexed<VideoListItem>(items) { index, item -> columns = GridCells.Fixed(2),
state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) {
itemsIndexed(items) { index, item ->
val video: VideoDto = when (item) { val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.HistoryEntry -> item.history.video
is VideoListItem.Video -> item.video is VideoListItem.Video -> item.video
@ -92,6 +109,8 @@ fun VideoListScreen(
VideoCard( VideoCard(
video = video, video = video,
onClick = { onOpenVideo(video) }, onClick = { onOpenVideo(video) },
showPrivacy = source is ListSource.MyVideos,
modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier,
) )
if (index >= items.size - PagedListViewModel.LOOKAHEAD) { if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() } LaunchedEffect(index) { viewModel.loadMore() }