diff --git a/AGENTS.md b/AGENTS.md
index 58ee33e..b08eb14 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,10 +113,11 @@ the workflow is strict:
## Scope discipline
-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.
+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.
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
deleted file mode 100644
index 1ee7edd..0000000
--- a/BACKLOG.md
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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 12c9cbf..f2add78 100644
--- a/README.md
+++ b/README.md
@@ -4,71 +4,91 @@
-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.
+
+ Watch your PeerTube instance from the couch, with the remote.
+
-It talks to any PeerTube instance over the
+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
[REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default
-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.
+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.
## What it looks like
-Screenshots come from a demo instance serving real, CC-licensed open-movie
+The screenshots use a public demo instance with 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.
-The home screen. Continue watching picks up at the position the server saved,
-new videos first, page after page.
+**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.
-
+
-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.
+**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.
+**My videos.** Everything you own, every privacy level, with a badge on
+anything that isn't public.
+
-Channels, one row per owner, with video and play counts on every card.
+**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.
-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.
+**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.
-## What it does in v0.1
+## What it does
-- 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.
+- **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.
-What it deliberately does not do (yet, or ever): uploading, editing,
-deleting, managing subscriptions. That's tracked in
-[BACKLOG.md](BACKLOG.md).
+What it deliberately does not do: uploading, editing or deleting content.
## Build
@@ -102,4 +122,6 @@ A single-module app, deliberately small:
## License
-JTube TV is free software under [GPL-3.0-or-later](LICENSE).
\ No newline at end of file
+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.
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 3a10e30..2c811d3 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,7 +10,6 @@ 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 {
@@ -56,16 +55,6 @@ 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) {
@@ -74,24 +63,10 @@ class VideoListViewModel(
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
}
}
- 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
- }
- }
- }
- }
- }
+ // 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.
}
override fun onChunkLoaded(chunk: List) {
@@ -103,20 +78,6 @@ 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> {
@@ -150,22 +111,9 @@ 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 ced63ff..6ca82bb 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,6 +4,10 @@ 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
@@ -93,15 +97,16 @@ 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 = when (mode) {
- "playlists" -> "Playlists"
- "info" -> "Info"
- else -> "Comments"
- },
+ text = if (mode == "playlists") "Playlists" else detail.name,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(20.dp),
+ 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),
)
when (mode) {
"playlists" -> PlaylistsColumn(detail, onDismiss)
@@ -161,68 +166,84 @@ 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() }
- LaunchedEffect(links) {
- if (links.isNotEmpty()) {
- kotlinx.coroutines.delay(150)
- runCatching { firstLinkFocus.requestFocus() }
+ // 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()
}
}
- LazyColumn(Modifier.fillMaxSize()) {
+ Column(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
- 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),
- )
- }
+ DescriptionScroll(
+ annotated = annotated,
+ focusRequester = descFocus,
+ onDismiss = onDismiss,
+ onLeaveBottom = {
+ if (links.isNotEmpty()) {
+ runCatching { firstLinkFocus.requestFocus() }.isSuccess
+ } else false
+ },
+ modifier = Modifier.weight(1f, fill = false),
+ )
}
if (links.isNotEmpty()) {
- 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,
- )
+ 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,
+ )
+ }
}
}
}
@@ -230,6 +251,64 @@ 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 203fbf7..131a64d 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,6 +37,7 @@ 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
@@ -63,9 +64,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
@@ -93,10 +94,6 @@ 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())
@@ -126,20 +123,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
private var lastLoadedAt = 0L
- 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()
- }
- }
-
+ // 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.
fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window:
@@ -224,11 +210,6 @@ 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)
}
@@ -302,12 +283,6 @@ 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 ->
@@ -439,6 +414,7 @@ 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,
@@ -481,6 +457,8 @@ 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),
) {
@@ -553,7 +531,7 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
- AsyncImage(
+ ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
@@ -567,7 +545,7 @@ fun ChannelCard(
}
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
- AsyncImage(
+ ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
@@ -576,12 +554,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)
- .background(Color(0xFF333333)),
+ .clip(CircleShape),
)
}
}
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 bca62ff..7ebda20 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,6 +14,7 @@ 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
@@ -40,7 +41,6 @@ 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,8 +57,6 @@ 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)
@@ -75,36 +73,24 @@ 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)
- 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
- }
- }
- }
- }
- }
+ // 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.
private fun fetchSubs() {
viewModelScope.launch {
@@ -115,12 +101,6 @@ 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)
- }
- }
}
}
@@ -176,6 +156,7 @@ 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()) {
@@ -183,6 +164,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
}
continueInFlight = false
+ continueLoadingMore = false
}
}
@@ -190,6 +172,7 @@ 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
@@ -199,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
+ subsLoadingMore = false
}
}
@@ -210,12 +194,6 @@ 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)
- }
- }
}
}
@@ -235,28 +213,8 @@ 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> =
@@ -314,9 +272,10 @@ fun HomeRowsScreen(
}
item {
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
- // Dummy cards with the faint shimmer while the row loads (#19);
+ // 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.
// "Watch something first" only when there is genuinely no history.
- ShimmerRow()
+ ShimmerRow(size = 1)
} else if (viewModel.continueItems.isEmpty()) {
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text(
@@ -338,6 +297,7 @@ 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) {
@@ -357,7 +317,7 @@ fun HomeRowsScreen(
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = recentItems,
@@ -367,6 +327,7 @@ 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() }
@@ -385,7 +346,7 @@ fun HomeRowsScreen(
}
item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = viewModel.subItems,
@@ -395,6 +356,7 @@ fun HomeRowsScreen(
clickedIndex = subRestore,
restoreToken = restoreToken,
refocusPulse = drawerPulse,
+ trailingLoading = viewModel.subsLoadingMore,
) { index, video ->
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.subsMore() }
@@ -424,6 +386,7 @@ 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,
@@ -442,6 +405,9 @@ 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()
@@ -489,6 +455,10 @@ 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),
) {
@@ -512,5 +482,14 @@ 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 e14554d..019e2ff 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,19 +114,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}
}
- // BACK order (TV standard): player (own handler) → drawer close → channel
- // drilldown → non-Home destination → Home; from Home: double-press exits.
+ // 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
BackHandler(enabled = drawerOpen && playingVideo == 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 {
+ if (destination == Destination.Home && channelDetails == null) {
val now = System.currentTimeMillis()
if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish()
@@ -134,8 +130,16 @@ 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
@@ -151,7 +155,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
when {
drawerOpen -> { dismissDrawer(); true }
channelDetails != null -> { channelDetails = null; true }
- else -> false
+ // 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
},
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 d80fa37..0a1c3a2 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()
- AsyncImage(
+ ShimmerAsyncImage(
model = req,
contentDescription = video.name,
contentScale = ContentScale.Crop,
@@ -320,6 +320,48 @@ 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 571e02f..cedb561 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,8 +1,11 @@
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
@@ -159,22 +162,29 @@ 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.fillMaxSize().padding(vertical = 8.dp),
+ modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
) {
- // 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 -> {}
+ // 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)
}
}
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@@ -239,21 +249,18 @@ fun VideoListScreen(
}
}
if (viewModel.loadingMore) {
- // 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()),
- )
- }
- }
+ // 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),
+ )
}
}
}
+ }
}
@Composable
@@ -396,95 +403,216 @@ private fun ScopeChip(
}
}
-/** 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. */
+/**
+ * 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.
+ */
@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(0xFF9A9A9A)
- androidx.compose.foundation.layout.Column {
- val banner = channelHeader.banners.maxByOrNull { it.width }
+ 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)),
+ ) {
if (banner != null) {
- AsyncImage(
+ ShimmerAsyncImage(
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
contentDescription = null,
contentScale = ContentScale.Crop,
- modifier = Modifier
- .fillMaxWidth()
- .height(120.dp)
- .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
+ modifier = Modifier.matchParentSize(),
)
}
- 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),
- )
- }
- 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,
- )
- }
- }
- // 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),
+ // 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),
) {
- 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)
+ 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,
+ )
+ }
+ }
}
- if (channelHeader.createdAt != null) {
- headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
+ 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)
+ }
}
}
- 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),
- )
- }
+ // 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
+ }
+ .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
+ }
+ }
+ .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,
+ )
}
}
diff --git a/docs/peertube-logo.svg b/docs/peertube-logo.svg
new file mode 100644
index 0000000..4f07ef9
--- /dev/null
+++ b/docs/peertube-logo.svg
@@ -0,0 +1,6 @@
+
diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png
index 11d2afe..f88dc4e 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 7b27e6c..5559078 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 5a2d6dd..1e150b8 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 c0b1af9..5321a83 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 303e0c0..f722916 100644
Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ