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:
parent
83dde8ba74
commit
43056065e1
14 changed files with 532 additions and 143 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
195
app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt
Normal file
195
app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue