diff --git a/AGENTS.md b/AGENTS.md
index b08eb14..58ee33e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,11 +113,10 @@ the workflow is strict:
## Scope discipline
-Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
-git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
-creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
-in scope and already implemented — on the player and the channel page.) If the user asks
-for a scope change, confirm it with them before implementing.
+v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in
+uploading, editing, subscriptions management, deletion or content creation — the user
+explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md`
+first, then implement.
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).
\ No newline at end of file
diff --git a/BACKLOG.md b/BACKLOG.md
new file mode 100644
index 0000000..1ee7edd
--- /dev/null
+++ b/BACKLOG.md
@@ -0,0 +1,85 @@
+# Backlog
+
+Status vocabulary: `todo` · `doing` · `done`.
+
+## v0.1 — MVP (implemented, pending device sign-off)
+
+| # | Item | Status | Notes |
+|---|------|--------|-------|
+| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation |
+| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too |
+| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 |
+| 3 | Search local videos | done | Own screen in sidebar |
+| 4 | Subscription videos | done | Verified incl. remote (federated) channels |
+| 5 | Watch history (server) | done | read-only grid |
+| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable |
+| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon |
+
+## Discussed but deferred (2026-10-12, while reworking the player controls)
+
+- Like/dislike display: counts are in the video detail DTO; rating (own like via
+ `PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later.
+- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration`
+ on the player; language selection in the controls overlay.
+
+## Next up (v0.2+)
+
+UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll
+memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus
+(tv-material Cards with scale/border), relative dates on cards, privacy badge only on
+My videos, password reveal + paste, overscan margins, sign-out entry, neutral form
+focus colors, always-focusable Sign in with inline validation. Deferred from review:
+
+- "Continue watching" refinement: auto-remove finished entries, exact resume (start
+ playback at `userHistory.currentTime`, show "Resuming at 1:23" toast).
+- Trending/featured row on Home; rows per subscribed channel (SmartTube-like).
+- Video progress bars in ALL lists (needs per-video history merge; currently only
+ Continue-watching has them).
+- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed;
+ auto-next after video end; subtitles (captions) support; phone-QR-assisted login.
+- Drawer restructure proposal: Home / Channels (subs first) / Search / History /
+ Account — decide together with merged-feed item below.
+- Unified "accessible videos" feed: merge public-local + own private into one paged,
+ deduplicated list (v0.1 keeps Home rows + My videos).
+- Global local-channels browse (search endpoint rejects empty queries) — consider
+ `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`.
+- Logout / switch account: sign-out entry exists; add switch-account (re-login screen
+ without losing scroll state).
+- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning.
+- Thumbnails of private/internal videos may 404 — on some instances thumbnail
+ paths are publicly readable while on others they need auth; add a fallback
+ poster only if needed.
+- Security note (verified live against tube.jeena.net): some instances serve
+ non-public media / thumbnails from object storage with only URL-based access
+ control. The app must not rely on that laxness; always send auth headers
+ / file tokens for non-public media. (Detail intentionally omitted from the
+ public repo — report instance-specific findings privately.)
+- Verify `searchTarget=local` channel search behaves well for instances without
+ federation; consider `search/video-channels` endpoint as an alternative.
+- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.
+- Verify `searchTarget=local` channel search behaves well for instances without
+ federation; consider `search/video-channels` endpoint as an alternative.
+- (Verified 2026-10-10 via the test account) history subscriptions + watching endpoints
+ behave as documented; upload seeding uses **flat multipart form fields** on
+ `/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for
+ seeding test data, the app itself doesn't upload.
+- Error UX: friendlier messages (offline, instance down, wrong credentials vs. expired
+ token → re-login screen), retry buttons.
+- i18n: move user-facing strings to `strings.xml`, consider German + English.
+- Video detail screen (description, tags, views, duration, channel page link).
+
+## Longer term
+
+- Multiple saved instances / switching between them.
+- Live streams playback (HLS live), premieres.
+- Subtitles/captions selection.
+- Downloads for offline watching (probably limited to public videos).
+- Comments viewing (read-only) — needs moderation-safe UI thinking.
+- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
+- Out of scope by user request (do not implement without asking): uploading, editing,
+ creating channels/users. (Stale members of this list — subscribing, playlists
+ viewing/creation and rating — shipped with the player action row in v0.1; playlist
+ deletion was requested and added later, see below.)
+- Delete own playlist: long-press OK on a playlist row (key-repeat) → confirmation
+ dialog → `DELETE /api/v1/video-playlists/{id}`; toggle stays on short press. Requested
+ 2026-10-13.
\ No newline at end of file
diff --git a/README.md b/README.md
index f2add78..12c9cbf 100644
--- a/README.md
+++ b/README.md
@@ -4,91 +4,71 @@
-
- Watch your PeerTube instance from the couch, with the remote.
-
+JTube TV is an Android TV app for [PeerTube](https://joinpeertube.org/). It puts
+a PeerTube instance on the big screen the way SmartTube puts YouTube there:
+browse from the couch, play with the remote, nothing else in the way.
-JTube TV is an Android TV app for
- PeerTube.
-It does for a PeerTube instance what SmartTube does for YouTube: a big-screen,
-D-pad-first way to browse your videos, your channels and your watch history and
-play them — and nothing else in the way.
-
-It is built for people who self-host their videos on PeerTube and want to watch
-them on the TV without a phone, a cast button or a browser. Sign in once with
-your PeerTube account and the whole instance is a few clicks away on the remote:
-your own uploads (private ones included), the channels you follow, what you were
-watching last, and the instance's public videos and search.
-
-It works with any PeerTube instance over the
+It talks to any PeerTube instance over the
[REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default
-in the sign-in screen is [tube.jeena.net](https://tube.jeena.net), the author's
-own instance, but you can point it at yours.
+is [tube.jeena.net](https://tube.jeena.net), my own instance, where most videos
+are private family uploads. Signing in matters because "My videos" shows them
+all, privacy levels included.
## What it looks like
-The screenshots use a public demo instance with real, CC-licensed open-movie
+Screenshots come from a demo instance serving real, CC-licensed open-movie
content, courtesy of the [Blender Foundation](https://www.blender.org/) movies
-and the [A propos de PeerTube](https://framatube.org) channel — all of it plays
+and the [A propos de PeerTube](https://framatube.org) channel. All of it plays
in the app.
-**Home.** Three rows: *Continue watching* picks up each video at the position
-the server saved, then the newest local videos, then the newest from the
-channels you follow. Scroll right and more load on their own.
+The home screen. Continue watching picks up at the position the server saved,
+new videos first, page after page.
-
+
-**The menu.** A sidebar slides in — press the left D-pad at the edge of a row,
-or just press Back from anywhere in a list. It jumps to Home, My videos,
-Channels, Search, Subscriptions and History.
+A slide-in drawer opens with the left D-pad button. My videos shows your own
+uploads, every privacy level included, with a badge on anything not public.
-**My videos.** Everything you own, every privacy level, with a badge on
-anything that isn't public.
-
-**Channels.** One row per owner, with video, subscriber and play counts on
-every card. Open a channel for its banner, its stats and all its videos — and a
-Subscribe button you can reach with the remote.
+Channels, one row per owner, with video and play counts on every card.
-**The player** speaks remote: play and pause, seek back and forward, a seek bar
-with position and duration, and the video's info, comments and playlists a
-button away. Watching is reported to the server every 20 seconds, so where you
-left off survives closing the app.
+The player speaks remote: play and pause, seek back and forward, a seek bar
+with position and duration. Watching is reported to the server every 20
+seconds, so where you left off survives the app.
-## What it does
+## What it does in v0.1
-- **Sign in once.** The app trades your username and password for OAuth tokens
- and keeps only those, encrypted with the Android Keystore. The password is
- never stored.
-- **Home** with Continue watching, new local videos and new videos from your
- subscriptions.
-- **Browse and search** the instance's public videos by title.
-- **My videos** — every video you own, private, internal and unlisted included.
-- **Channels** — local channels with their videos, plus your subscriptions and
- your server-side watch history.
-- **Subscribe** to and unsubscribe from channels, from the player or a channel
- page.
-- **Player** — HLS with a progressive MP4 fallback, resume from the saved
- position, like/dislike, comments, playlists, and watching reports.
+- Sign in once. The app exchanges your username and password for OAuth tokens
+ and stores only those, encrypted with the Android Keystore. The password
+ itself is never kept.
+- Home with Continue watching and New videos.
+- Browse the instance's public videos, and search them by title.
+- My videos: every video you own, private, internal and unlisted included.
+- Channels: local channels with their videos, plus your subscriptions and your
+ server-side watch history.
+- Player: HLS, with a progressive MP4 fallback, resume from the saved
+ position, and watching reports.
-What it deliberately does not do: uploading, editing or deleting content.
+What it deliberately does not do (yet, or ever): uploading, editing,
+deleting, managing subscriptions. That's tracked in
+[BACKLOG.md](BACKLOG.md).
## Build
@@ -122,6 +102,4 @@ A single-module app, deliberately small:
## License
-JTube TV is free software under [GPL-3.0-or-later](LICENSE). The PeerTube logo
-is a trademark of [Framasoft](https://framasoft.org/), used here to refer to the
-PeerTube project.
+JTube TV is free software under [GPL-3.0-or-later](LICENSE).
\ No newline at end of file
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt
index 2c811d3..3a10e30 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
+import net.jeena.jtubetv.ui.list.Snapshots
/** Where the list's content comes from. */
sealed interface ListSource {
@@ -55,6 +56,16 @@ class VideoListViewModel(
var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
private set
+ // Snapshot caching (issue #9): live content lists only — Search results and
+ // History rows (which carry extra watch metadata) must not persist.
+ private val shouldSkipSnapshot =
+ source is ListSource.Search || source is ListSource.History
+
+ private fun cacheKey(): String = when (val s = source) {
+ is ListSource.Channel -> "channel-" + s.handle
+ else -> s::class.java.simpleName
+ }
+
init {
// Probe whether the instance allows the search-index ("Vidiverse") scope.
if (source is ListSource.Search) {
@@ -63,10 +74,24 @@ class VideoListViewModel(
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
}
}
- // No snapshot instant-paint any more (issue #25): the saved-list cache
- // repainted stale rows that then jumped when the fresh page landed with
- // newer videos. Screens now show the loading card until the first page
- // arrives; images stay fast via Coil's own disk cache.
+ val context = AppGraph.appContext
+ if (context != null && !shouldSkipSnapshot) {
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ showInstant(videos.map { VideoListItem.Video(it) })
+ }
+ // Channel page banner: restore the saved one instantly too.
+ val s = source
+ if (s is ListSource.Channel) {
+ Snapshots.loadChannelHeader(context, s.handle)?.let { header ->
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ channelHeader = header
+ }
+ }
+ }
+ }
+ }
}
override fun onChunkLoaded(chunk: List) {
@@ -78,6 +103,20 @@ class VideoListViewModel(
}
}
)
+ // Persist the freshly loaded content so the next app start paints
+ // instantly (issue #9).
+ if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) {
+ val context = AppGraph.appContext
+ if (context != null) {
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveVideos(
+ context,
+ cacheKey(),
+ items.filterIsInstance().take(50).map { it.video },
+ )
+ }
+ }
+ }
}
override suspend fun fetch(start: Long, count: Int): Result> {
@@ -111,9 +150,22 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source
if (s is ListSource.Channel) {
+ // Instant restore from the snapshot, then a fresh fetch (issue #9).
+ val context = AppGraph.appContext
+ if (context != null) {
+ Snapshots.loadChannelHeader(context, s.handle)?.let { snap ->
+ if (channelHeader == null) channelHeader = snap
+ }
+ }
viewModelScope.launch {
repository.videoChannel(s.handle).getOrNull()?.let { header ->
channelHeader = header
+ val ctx = AppGraph.appContext
+ if (ctx != null) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveChannelHeader(ctx, s.handle, header)
+ }
+ }
}
}
}
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
index 6ca82bb..ced63ff 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
@@ -4,10 +4,6 @@ import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
-import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -97,16 +93,15 @@ fun PlayerSidebar(
.width(SIDEBAR_WIDTH)
.background(SidebarBg),
) {
- // Header = the video's title (issue #23/#24): the old generic "Info" /
- // "Comments" label read as an empty black bar with no context about
- // which video the panel belongs to. Playlists keeps its own label.
Text(
- text = if (mode == "playlists") "Playlists" else detail.name,
+ text = when (mode) {
+ "playlists" -> "Playlists"
+ "info" -> "Info"
+ else -> "Comments"
+ },
color = Color.White,
style = MaterialTheme.typography.titleMedium,
- maxLines = 2,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 10.dp),
+ modifier = Modifier.padding(20.dp),
)
when (mode) {
"playlists" -> PlaylistsColumn(detail, onDismiss)
@@ -166,84 +161,68 @@ private fun InfoColumn(
.distinct()
.toList()
- val annotated = buildAnnotatedString {
- var rest = description
- while (true) {
- val match = UrlPattern.find(rest) ?: break
- append(rest.substringBefore(match.value))
- val url = match.value.trimEnd('.', ',', ')')
- withLink(
- LinkAnnotation.Url(
- url,
- TextLinkStyles(
- SpanStyle(
- color = Color(0xFF66B2FF),
- textDecoration = TextDecoration.Underline,
- ),
- ),
- { _ -> open(url) },
- ),
- ) { append(url) }
- rest = rest.substringAfter(match.value)
- }
- append(rest)
- }
-
- val descFocus = remember { FocusRequester() }
val firstLinkFocus = remember { FocusRequester() }
- // Focus the description so the D-pad can scroll it right away (issue #23):
- // a description with no links used to have nothing focusable, so a long
- // text could never be scrolled down with the remote. Fall back to the
- // first link when there is no description text.
- LaunchedEffect(Unit) {
- kotlinx.coroutines.delay(150)
- runCatching {
- if (description.isNotBlank()) descFocus.requestFocus()
- else firstLinkFocus.requestFocus()
+ LaunchedEffect(links) {
+ if (links.isNotEmpty()) {
+ kotlinx.coroutines.delay(150)
+ runCatching { firstLinkFocus.requestFocus() }
}
}
- Column(Modifier.fillMaxSize()) {
+ LazyColumn(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
- DescriptionScroll(
- annotated = annotated,
- focusRequester = descFocus,
- onDismiss = onDismiss,
- onLeaveBottom = {
- if (links.isNotEmpty()) {
- runCatching { firstLinkFocus.requestFocus() }.isSuccess
- } else false
- },
- modifier = Modifier.weight(1f, fill = false),
- )
+ item(key = "desc") {
+ Text(
+ text = buildAnnotatedString {
+ var rest = description
+ while (true) {
+ val match = UrlPattern.find(rest) ?: break
+ append(rest.substringBefore(match.value))
+ val url = match.value.trimEnd('.', ',', ')')
+ withLink(
+ LinkAnnotation.Url(
+ url,
+ TextLinkStyles(
+ SpanStyle(
+ color = Color(0xFF66B2FF),
+ textDecoration = TextDecoration.Underline,
+ ),
+ ),
+ { _ -> open(url) },
+ ),
+ ) { append(url) }
+ rest = rest.substringAfter(match.value)
+ }
+ append(rest)
+ },
+ color = TextMain,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
+ )
+ }
}
if (links.isNotEmpty()) {
- Text(
- text = "Links",
- color = TextDim,
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
- )
- LazyColumn(Modifier.heightIn(max = 220.dp)) {
- links.forEachIndexed { index, url ->
- item(key = "l" + index + url) {
- SidebarRow(
- onLeft = onDismiss,
- onUp = if (index == 0 && description.isNotBlank()) {
- { runCatching { descFocus.requestFocus() } }
- } else null,
- atTop = index == 0 && description.isBlank(),
- atBottom = index == links.size - 1,
- focusRequester = if (index == 0) firstLinkFocus else null,
- onClick = { open(url) },
- ) {
- Text(
- text = linkLabel(url),
- color = TextMain,
- style = MaterialTheme.typography.bodySmall,
- maxLines = 1,
- )
- }
+ item(key = "linkshdr") {
+ Text(
+ text = "Links",
+ color = TextDim,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
+ )
+ }
+ links.forEachIndexed { index, url ->
+ item(key = "l" + index + url) {
+ SidebarRow(
+ onLeft = onDismiss,
+ focusRequester = if (index == 0) firstLinkFocus else null,
+ onClick = { open(url) },
+ ) {
+ Text(
+ text = linkLabel(url),
+ color = TextMain,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ )
}
}
}
@@ -251,64 +230,6 @@ private fun InfoColumn(
}
}
-/**
- * Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a
- * LazyColumn can't be scrolled with the remote — nothing in it takes focus — so
- * long descriptions were unreadable past the first screenful. This box takes
- * focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands
- * focus to the link list (if any).
- */
-@Composable
-private fun DescriptionScroll(
- annotated: androidx.compose.ui.text.AnnotatedString,
- focusRequester: FocusRequester,
- onDismiss: () -> Unit,
- onLeaveBottom: () -> Boolean,
- modifier: Modifier = Modifier,
-) {
- val scroll = androidx.compose.foundation.rememberScrollState()
- val scope = rememberCoroutineScope()
- var focused by remember { mutableStateOf(false) }
- var viewportPx by remember { mutableStateOf(0) }
- Box(
- modifier
- .fillMaxWidth()
- .onFocusChanged { focused = it.isFocused }
- .focusRequester(focusRequester)
- .focusable()
- .background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
- .onPreviewKeyEvent { ke ->
- if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
- val step = (viewportPx * 0.85f).coerceAtLeast(240f)
- when (ke.nativeKeyEvent.keyCode) {
- KeyEvent.KEYCODE_DPAD_DOWN ->
- if (scroll.value < scroll.maxValue) {
- scope.launch { scroll.animateScrollBy(step) }
- true
- } else onLeaveBottom()
- KeyEvent.KEYCODE_DPAD_UP ->
- if (scroll.value > 0) {
- scope.launch { scroll.animateScrollBy(-step) }
- true
- } else true // nothing above the description; stay put
- KeyEvent.KEYCODE_DPAD_LEFT -> {
- onDismiss(); true
- }
- else -> false
- }
- }
- .verticalScroll(scroll)
- .onSizeChanged { viewportPx = it.height },
- ) {
- Text(
- text = annotated,
- color = TextMain,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
- )
- }
-}
-
/** Link click listener: the annotation callback shape. */
private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt
index 131a64d..203fbf7 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt
@@ -37,7 +37,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -64,9 +63,9 @@ import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoDto
+import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape
-import net.jeena.jtubetv.ui.screens.list.ShimmerAsyncImage
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
@@ -94,6 +93,10 @@ data class UserGroup(
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
+ private val snapshotContext = AppGraph.appContext
+ private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_")
+ private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) }
+
var user by mutableStateOf(null)
private set
var userChannels by mutableStateOf>(emptyList())
@@ -123,9 +126,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
private var lastLoadedAt = 0L
- // No snapshot instant-paint any more (issue #25): the Channels screen shows
- // its shimmer placeholder rows until the (fast-path, few-request) load lands,
- // instead of painting a stale snapshot that reshuffled as stats streamed in.
+ init {
+ // Snapshot restore (issue #9): paint the last session's channel rows
+ // instantly, including the per-channel stats (the "minutes of loading"
+ // complaint) — the streaming reload below then updates them in place.
+ if (snapshotRows != null) {
+ userRows = snapshotRows
+ loading = false
+ }
+ if (snapshotContext != null) {
+ statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey))
+ if (statsMap.isNotEmpty()) stats = statsMap.toMap()
+ }
+ }
+
fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window:
@@ -210,6 +224,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
stats = statsMap.toMap()
rebuildRows(host)
lastLoadedAt = android.os.SystemClock.elapsedRealtime()
+ snapshotContext?.let { ctx ->
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
+ }
+ }
ImagePrefetcher.channelArt(allChannels + userChannels)
}
@@ -283,6 +302,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { it.followersCount } },
)
+ // Persist for the next cold start (issue #9).
+ snapshotContext?.let { ctx ->
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveUserGroups(ctx, snapshotKey, userRows)
+ }
+ }
Log.w(
"JTube",
"order now: " + userRows.take(6).joinToString(" | ") { g ->
@@ -414,7 +439,6 @@ private fun absoluteAvatar(avatar: Avatar): String =
}
/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
-@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
private fun ChannelRow(
channels: List,
@@ -457,8 +481,6 @@ private fun ChannelRow(
LazyRow(
state = rowState,
- // Land on the last-focused card when moving between rows (issue #29).
- modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -531,7 +553,7 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
- ShimmerAsyncImage(
+ AsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
@@ -545,7 +567,7 @@ fun ChannelCard(
}
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
- ShimmerAsyncImage(
+ AsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
@@ -554,12 +576,12 @@ fun ChannelCard(
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
- base = Color(0xFF333333),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
.size(40.dp)
- .clip(CircleShape),
+ .clip(CircleShape)
+ .background(Color(0xFF333333)),
)
}
}
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt
index 7ebda20..bca62ff 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.focusRestorer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -41,6 +40,7 @@ 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.Snapshots
import net.jeena.jtubetv.ui.screens.list.ShimmerCard
import net.jeena.jtubetv.ui.screens.list.VideoCard
@@ -57,6 +57,8 @@ data class ContinueItem(val video: VideoDto, val progress: Float?)
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
+ private fun contextOrNull() = AppGraph.appContext
+
var continueItems by mutableStateOf>(emptyList())
private set
var continueLoading by mutableStateOf(true)
@@ -73,24 +75,36 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private var continueInFlight = false
var continueExhausted by mutableStateOf(false)
private set
- /** True while the next continue page is loading — drives a trailing loading card (#25). */
- var continueLoadingMore by mutableStateOf(false)
- private set
private var subsStart = 0L
private var subsInFlight = false
var subsExhausted by mutableStateOf(false)
private set
- var subsLoadingMore by mutableStateOf(false)
- private set
/** Exposed for the shimmer placeholders (issue #19). */
var subsLoading = false
private set
val recent = RecentVideosViewModel(repository)
- // No snapshot instant-paint any more (issue #25): Home shows one loading card
- // per row until the network pages land, instead of painting a stale snapshot
- // that then jumped as newer videos arrived.
+ init {
+ // Snapshot restore (issue #9): Home paints from the last session's rows
+ // immediately; the loads below freshen them from the network.
+ val context = contextOrNull()
+ if (context != null) {
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ val savedContinue = Snapshots.loadContinue(context, "home-continue")
+ val savedSubs = Snapshots.loadVideos(context, "home-subs")
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ if (continueItems.isEmpty() && savedContinue != null) {
+ continueItems = savedContinue
+ continueLoading = false
+ }
+ if (subItems.isEmpty() && savedSubs != null) {
+ subItems = savedSubs
+ }
+ }
+ }
+ }
+ }
private fun fetchSubs() {
viewModelScope.launch {
@@ -101,6 +115,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
subItems = subs
subsLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
+ // Snapshot for the next cold start (issue #9).
+ contextOrNull()?.let { ctx ->
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveVideos(ctx, "home-subs", subs)
+ }
+ }
}
}
@@ -156,7 +176,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun continueMore() {
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
continueInFlight = true
- continueLoadingMore = true
viewModelScope.launch {
val page = fetchContinuePage()
if (page.isNotEmpty()) {
@@ -164,7 +183,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
}
continueInFlight = false
- continueLoadingMore = false
}
}
@@ -172,7 +190,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun subsMore() {
if (subsInFlight || subsExhausted || subItems.isEmpty()) return
subsInFlight = true
- subsLoadingMore = true
viewModelScope.launch {
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
subsStart += page.size
@@ -182,7 +199,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
- subsLoadingMore = false
}
}
@@ -194,6 +210,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
continueItems = items
continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
+ // Snapshot for the next cold start (issue #9).
+ contextOrNull()?.let { ctx ->
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveContinue(ctx, "home-continue", items)
+ }
+ }
}
}
@@ -213,8 +235,28 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
/** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() {
+ // Instant restore from the last session (issue #9); showInstant() refreshes
+ // the first page in the background.
+ init {
+ val context = AppGraph.appContext
+ if (context != null) {
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.loadVideos(context, "RecentVideos")?.let { snapshot ->
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ showInstant(snapshot)
+ }
+ }
+ }
+ }
+ }
+
override fun onChunkLoaded(chunk: List) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
+ AppGraph.appContext?.let { ctx ->
+ viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
+ Snapshots.saveVideos(ctx, "RecentVideos", items.take(50))
+ }
+ }
}
override suspend fun fetch(start: Long, count: Int): Result> =
@@ -272,10 +314,9 @@ fun HomeRowsScreen(
}
item {
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
- // One placeholder card per row while it loads (#25): a single card
- // reads as "this row is coming", not a fake full row of content.
+ // Dummy cards with the faint shimmer while the row loads (#19);
// "Watch something first" only when there is genuinely no history.
- ShimmerRow(size = 1)
+ ShimmerRow()
} else if (viewModel.continueItems.isEmpty()) {
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text(
@@ -297,7 +338,6 @@ fun HomeRowsScreen(
autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated },
refocusPulse = drawerPulse,
- trailingLoading = viewModel.continueLoadingMore,
) { index, video ->
// Grow the row on demand (issue #18), same hook as the recent row.
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
@@ -317,7 +357,7 @@ fun HomeRowsScreen(
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
- ShimmerRow(size = 1)
+ ShimmerRow()
} else {
FocusScrollRow(
videos = recentItems,
@@ -327,7 +367,6 @@ fun HomeRowsScreen(
clickedIndex = recentRestore,
restoreToken = restoreToken,
refocusPulse = drawerPulse,
- trailingLoading = viewModel.recent.loadingMore,
) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
@@ -346,7 +385,7 @@ fun HomeRowsScreen(
}
item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
- ShimmerRow(size = 1)
+ ShimmerRow()
} else {
FocusScrollRow(
videos = viewModel.subItems,
@@ -356,7 +395,6 @@ fun HomeRowsScreen(
clickedIndex = subRestore,
restoreToken = restoreToken,
refocusPulse = drawerPulse,
- trailingLoading = viewModel.subsLoadingMore,
) { index, video ->
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.subsMore() }
@@ -386,7 +424,6 @@ fun ShimmerRow(size: Int = 4) {
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of
* the abrupt jump the built-in lazy scroll produces.
*/
-@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun FocusScrollRow(
videos: List,
@@ -405,9 +442,6 @@ fun FocusScrollRow(
// NOTE: stays before [afterItem] — the trailing lambda binds to the last
// declared parameter.
refocusPulse: Int = 0,
- // A trailing loading card at the row's end while the next page loads (#25),
- // so it's clear more is coming when scrolling right.
- trailingLoading: Boolean = false,
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) {
val rowState = rememberLazyListState()
@@ -455,10 +489,6 @@ fun FocusScrollRow(
}
LazyRow(
state = rowState,
- // Jumping between rows lands on the card this row last had focus on, not
- // the geometrically-nearest one (issue #29): the rows stop shifting
- // left/right as you move up and down between them.
- modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -482,14 +512,5 @@ fun FocusScrollRow(
)
afterItem(index, video)
}
- if (trailingLoading) {
- item(key = "trailing-loading") {
- ShimmerCard(
- modifier = Modifier
- .padding(horizontal = 4.dp)
- .width(rowCardWidth()),
- )
- }
- }
}
}
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt
index 019e2ff..e14554d 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt
@@ -114,15 +114,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
}
- // BACK order (issue #26): the sidebar is the root of a list view, reached
- // with one BACK press instead of scrolling all the way left.
- // player → its own handler
- // drawer open, Home → double-press exits (the sidebar is the root)
- // drawer open, else → close the drawer
- // channel drilldown → back up to the list that opened it
- // any list view → open the sidebar
+ // BACK order (TV standard): player (own handler) → drawer close → channel
+ // drilldown → non-Home destination → Home; from Home: double-press exits.
BackHandler(enabled = drawerOpen && playingVideo == null) {
- if (destination == Destination.Home && channelDetails == null) {
+ dismissDrawer()
+ }
+ BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
+ channelDetails = null
+ }
+ BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) {
+ if (destination != Destination.Home) {
+ channelDetails = null
+ destination = Destination.Home
+ } else {
val now = System.currentTimeMillis()
if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish()
@@ -130,16 +134,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
lastBackAt = now
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show()
- } else {
- dismissDrawer()
}
}
- BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
- channelDetails = null
- }
- BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) {
- drawerOpen = true
- }
Box(
modifier = Modifier
@@ -155,9 +151,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
when {
drawerOpen -> { dismissDrawer(); true }
channelDetails != null -> { channelDetails = null; true }
- // Mirror the D-pad BACK model (issue #26): Esc opens the
- // sidebar from a top-level list view too.
- else -> { drawerOpen = true; true }
+ else -> false
}
} else false
},
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt
index 0a1c3a2..d80fa37 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt
@@ -129,7 +129,7 @@ fun VideoCard(
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build()
- ShimmerAsyncImage(
+ AsyncImage(
model = req,
contentDescription = video.name,
contentScale = ContentScale.Crop,
@@ -320,48 +320,6 @@ fun ShimmerCard(modifier: Modifier = Modifier) {
}
}
-/**
- * Coil image that shows the same faint shimmer as [ShimmerCard] while it loads
- * (issue #28) — thumbnails, channel banners and avatars no longer pop in from a
- * flat grey rectangle. The sweep runs only while loading: once the image (or an
- * error) arrives, the overlay leaves composition and its animation disposes.
- */
-@Composable
-fun ShimmerAsyncImage(
- model: Any?,
- contentDescription: String?,
- modifier: Modifier = Modifier,
- contentScale: ContentScale = ContentScale.Crop,
- base: Color = Color(0xFF222222),
-) {
- var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) }
- Box(modifier.background(base)) {
- AsyncImage(
- model = model,
- contentDescription = contentDescription,
- contentScale = contentScale,
- modifier = Modifier.matchParentSize(),
- onState = { state ->
- loaded = state is coil.compose.AsyncImagePainter.State.Success ||
- state is coil.compose.AsyncImagePainter.State.Error
- },
- )
- if (!loaded) ShimmerFill(Modifier.matchParentSize(), base)
- }
-}
-
-/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */
-@Composable
-private fun ShimmerFill(modifier: Modifier, base: Color) {
- val sweep = rememberInfiniteTransition()
- val x by sweep.animateFloat(
- initialValue = 0f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)),
- )
- Box(modifier.background(base).shimmer(x))
-}
-
/** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */
fun Modifier.shimmer(progress: Float): Modifier = drawBehind {
drawRect(
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt
index cedb561..571e02f 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt
@@ -1,11 +1,8 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth
-import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.VideoLibrary
-import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -162,29 +159,22 @@ fun VideoListScreen(
return
}
- androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
- // Channel page: the header is PINNED above the grid (issue #27) rather than
- // scrolling away as the first grid item — auto-focusing the first video card
- // no longer hides the banner, and the Subscribe button on it stays reachable.
- if (source is ListSource.Channel) {
- ChannelHeader(
- channelHeader = viewModel.channelHeader,
- videoTotal = viewModel.videoTotal,
- handle = source.handle,
- onLeftEdge = onOpenDrawer,
- )
- }
LazyVerticalGrid(
// Same card size as the Home rows: ~4 fit per row (user request).
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
state = gridState,
- modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
+ modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) {
- // Search field still scrolls with its results (its own header item);
- // the channel banner is pinned above the grid instead (issue #27).
- if (source is ListSource.Search) {
- item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
- SearchHeader(viewModel, onOpenDrawer)
+ // Page headers (search field / channel banner) live INSIDE the grid so
+ // they scroll with the content and never stack under the cards.
+ item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
+ when (source) {
+ is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
+ is ListSource.Channel -> ChannelHeader(
+ channelHeader = viewModel.channelHeader,
+ videoTotal = viewModel.videoTotal,
+ )
+ else -> {}
}
}
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@@ -249,18 +239,21 @@ fun VideoListScreen(
}
}
if (viewModel.loadingMore) {
- // Growing the list: a single loading card at the end (#25) so it's
- // clear more is coming, without faking a whole row of content.
- item(key = "load-more-placeholder") {
- ShimmerCard(
- modifier = Modifier
- .padding(6.dp)
- .width(rowCardWidth() - 12.dp),
- )
+ // Growing the list: placeholder row (issue #19) instead of the
+ // permanent spinner chip that used to sit under every list.
+ item(key = "load-more-placeholders") {
+ androidx.compose.foundation.layout.Row {
+ repeat(cardsPerRow) {
+ ShimmerCard(
+ modifier = Modifier
+ .padding(6.dp)
+ .width(rowCardWidth()),
+ )
+ }
+ }
}
}
}
- }
}
@Composable
@@ -403,216 +396,95 @@ private fun ScopeChip(
}
}
-/**
- * Channel page header (issue #27): the banner is the backdrop of a fixed-height
- * header, darkened with a bottom-up gradient, with the channel identity + stats
- * and a reachable Subscribe button laid over it. It's pinned above the grid so
- * it never scrolls away, and the video cards start directly beneath it.
- */
+/** Channel page header: banner strip, then avatar + display name + handle,
+ * a metadata strip (subscribers/videos/created + owner, issue #14) and the
+ * channel description snippet above the videos. */
@Composable
private fun ChannelHeader(
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
videoTotal: Long = 0,
- handle: String,
- onLeftEdge: () -> Unit = {},
) {
if (channelHeader == null) return
- val metaColor = Color(0xFFCFCFCF)
- val banner = channelHeader.banners.maxByOrNull { it.width }
- val description = channelHeader.description?.takeIf { it.isNotBlank() }
- androidx.compose.foundation.layout.Column(
- Modifier
- .fillMaxWidth()
- .padding(start = 8.dp, end = 8.dp, top = 4.dp)
- .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
- .background(Color.Black),
- ) {
- // Banner area: a fixed height with the identity + stats anchored to its
- // bottom. A long description flows into the black strip BELOW it rather than
- // being pushed up over the banner or clipped (user request).
- Box(
- Modifier
- .fillMaxWidth()
- .height(178.dp)
- .background(Color(0xFF1C1C1C)),
- ) {
+ val metaColor = Color(0xFF9A9A9A)
+ androidx.compose.foundation.layout.Column {
+ val banner = channelHeader.banners.maxByOrNull { it.width }
if (banner != null) {
- ShimmerAsyncImage(
+ AsyncImage(
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
contentDescription = null,
contentScale = ContentScale.Crop,
- modifier = Modifier.matchParentSize(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
)
}
- // Darkening overlay, fading to solid black at the bottom so the banner
- // blends seamlessly into the description strip beneath it.
- Box(
- Modifier
- .matchParentSize()
- .background(
- androidx.compose.ui.graphics.Brush.verticalGradient(
- 0f to Color(0x33000000),
- 0.5f to Color(0x99000000),
- 1f to Color(0xFF000000),
- ),
- ),
- )
- // Identity + stats, bottom-left. The avatar sits beside the title (as it
- // did before the redesign — user request).
- Column(
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- val avatar = channelHeader.avatars.maxByOrNull { it.width }
- if (avatar != null) {
- ShimmerAsyncImage(
- model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- base = Color(0xFF333333),
- modifier = Modifier
- .size(56.dp)
- .clip(androidx.compose.foundation.shape.CircleShape),
- )
- }
- Column(modifier = Modifier.padding(start = 14.dp)) {
- Text(
- text = channelHeader.displayName,
- color = Color.White,
- style = MaterialTheme.typography.titleLarge,
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- // "by on " instead of the raw handle (user
- // request): the handle read as noise on a personal instance.
- val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name }
- val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host
- val byline = when {
- owner != null && platform != null -> "by $owner on $platform"
- owner != null -> "by $owner"
- platform != null -> "on $platform"
- else -> ""
- }
- if (byline.isNotBlank()) {
- Text(
- text = byline,
- color = Color(0xFFBDBDBD),
- style = MaterialTheme.typography.bodySmall,
- maxLines = 1,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- )
- }
- }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val avatar = channelHeader.avatars.maxByOrNull { it.width }
+ if (avatar != null) {
+ AsyncImage(
+ model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .padding(start = 16.dp, top = 10.dp)
+ .size(52.dp)
+ .clip(androidx.compose.foundation.shape.CircleShape),
+ )
}
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
- modifier = Modifier.padding(top = 6.dp),
- ) {
- headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
- val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
- if (videoCount > 0) {
- headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
- }
- if (channelHeader.createdAt != null) {
- headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
- }
+ Column(modifier = Modifier.padding(start = 16.dp)) {
+ Text(
+ text = channelHeader.displayName,
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ )
+ Text(
+ text = channelHeader.name + "@" + channelHeader.host,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ )
}
}
- // Subscribe button, top-right over the banner; LEFT opens the sidebar so
- // the header is never a dead end (issue #27).
- SubscribeButton(
- handle = handle,
- onLeftEdge = onLeftEdge,
- modifier = Modifier
- .align(Alignment.TopEnd)
- .padding(16.dp),
- )
- }
- // Description in the black beneath the banner: a long one grows the header
- // downward, fading out of the banner that already darkens into black.
- if (description != null) {
- Text(
- text = description,
- color = Color(0xFFCCCCCC),
- style = MaterialTheme.typography.bodySmall,
- maxLines = 3,
- overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
- )
- }
- }
-}
-
-/**
- * Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
- * player's action: the server's follow-exists check can lag for federated
- * channels, so a successful toggle trusts the user's action and re-syncs on the
- * next screen entry. One request at a time ([busy]).
- */
-@Composable
-private fun SubscribeButton(
- handle: String,
- onLeftEdge: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- val repository = AppGraph.repository
- val scope = androidx.compose.runtime.rememberCoroutineScope()
- var subscribed by remember(handle) { mutableStateOf(null) }
- var busy by remember(handle) { mutableStateOf(false) }
- var focused by remember { mutableStateOf(false) }
- LaunchedEffect(handle) {
- repository.subscriptionExists(handle).onSuccess { subscribed = it }
- }
- val isSub = subscribed == true
- val bg = when {
- focused -> Color.White
- isSub -> Color(0x33CE2B2B)
- else -> Color(0xFFCE2B2B)
- }
- val fg = if (focused) Color(0xFF101010) else Color.White
- Row(
- modifier = modifier
- .clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
- .background(bg)
- .onFocusChanged { focused = it.isFocused }
- .onPreviewKeyEvent { ke ->
- if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
- ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
- ) {
- onLeftEdge(); true
- } else false
+ // Metadata strip between the identity and the videos (issue #14): the
+ // counts SmartTube shows, plus creation date and the owning user.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp),
+ ) {
+ headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
+ val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
+ if (videoCount > 0) {
+ headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
}
- .dpadCenterActivatable {
- if (busy) return@dpadCenterActivatable
- busy = true
- scope.launch {
- val wasSub = subscribed == true
- val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
- else repository.subscribe(handle).isSuccess
- if (ok) subscribed = !wasSub
- busy = false
- }
+ if (channelHeader.createdAt != null) {
+ headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
}
- .focusable()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
- ) {
- androidx.compose.material3.Icon(
- imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
- contentDescription = null,
- tint = fg,
- modifier = Modifier.size(18.dp),
- )
- Text(
- text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
- color = fg,
- style = MaterialTheme.typography.labelLarge,
- maxLines = 1,
- )
+ }
+ val owner = channelHeader.ownerAccount
+ if (owner != null) {
+ val host = channelHeader.host
+ val platform = AppGraph.tokenStore.instanceName ?: host
+ Text(
+ text = "Owned by " + (owner.displayName ?: owner.name) +
+ (if (platform != null) " - " + platform else ""),
+ color = metaColor,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ modifier = Modifier.padding(start = 16.dp, top = 4.dp),
+ )
+ }
+ channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
+ Text(
+ text = description,
+ color = Color(0xFFB8B8B8),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 3,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
+ )
+ }
}
}
diff --git a/docs/peertube-logo.svg b/docs/peertube-logo.svg
deleted file mode 100644
index 4f07ef9..0000000
--- a/docs/peertube-logo.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png
index f88dc4e..11d2afe 100644
Binary files a/docs/screenshots/channels.png and b/docs/screenshots/channels.png differ
diff --git a/docs/screenshots/drawer.png b/docs/screenshots/drawer.png
index 5559078..7b27e6c 100644
Binary files a/docs/screenshots/drawer.png and b/docs/screenshots/drawer.png differ
diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png
index 1e150b8..5a2d6dd 100644
Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ
diff --git a/docs/screenshots/myvideos.png b/docs/screenshots/myvideos.png
index 5321a83..c0b1af9 100644
Binary files a/docs/screenshots/myvideos.png and b/docs/screenshots/myvideos.png differ
diff --git a/docs/screenshots/player.png b/docs/screenshots/player.png
index f722916..303e0c0 100644
Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ