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

@ -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) {

View file

@ -110,8 +110,8 @@ class PeerTubeRepository(
// ---- playback ----------------------------------------------------------
suspend fun video(shortUuid: String): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid) }
suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid, userHistory = userHistory) }
suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> =
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 =

View file

@ -91,6 +91,11 @@ data class StreamingPlaylist(
val files: List<VideoFile> = 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<VideoFile> = emptyList(),
val streamingPlaylists: List<StreamingPlaylist> = emptyList(),
val userHistory: UserHistoryDto? = null,
)
@Serializable

View file

@ -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")

View file

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

View file

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

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.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<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(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

View file

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

View file

@ -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<String?>(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),
)

View file

@ -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"
}
}
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.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<VideoListItem>(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() }