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
43
BACKLOG.md
43
BACKLOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ android {
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 }
|
||||||
|
|
|
||||||
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.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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue