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..07c09d8 100644
--- a/README.md
+++ b/README.md
@@ -4,71 +4,97 @@
-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.
-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.
+Open a channel for its banner, its stats and all its videos — with 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, 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 +128,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/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt
index fe13971..a9d3f43 100644
--- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt
+++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt
@@ -1,6 +1,7 @@
package net.jeena.jtubetv
import android.app.Application
+import kotlinx.coroutines.Dispatchers
import coil.ImageLoader
import coil.ImageLoaderFactory
import net.jeena.jtubetv.data.PeerTubeRepository
@@ -33,9 +34,20 @@ class JTubeApp : Application(), coil.ImageLoaderFactory {
* TVs are slow compositors: decode thumbnails as RGB_565 at capped size so
* scrolling rows keep a smooth frame rate (user: "the animation has very low fps").
*/
- override fun newImageLoader(): ImageLoader =
- ImageLoader.Builder(this)
+ override fun newImageLoader(): ImageLoader {
+ // A sched trace on the TV (release build) showed the main thread being
+ // descheduled hundreds of times during navigation while the render
+ // pipeline (Mali/RenderThread/SF/Dolby) saturated all 4 cores together
+ // with Coil's image work — a burst of parallel 30 ms bitmap decodes
+ // starved the main thread for over a second (the felt "2fps"). Capping
+ // Coil's parallel work: images still fill in steadily while scrolling,
+ // but the decode/fetch pool can no longer eat every core at once.
+ return ImageLoader.Builder(this)
.bitmapConfig(android.graphics.Bitmap.Config.RGB_565)
.crossfade(false)
+ .dispatcher(Dispatchers.IO.limitedParallelism(2))
+ .decoderDispatcher(Dispatchers.Default.limitedParallelism(2))
+ .transformationDispatcher(Dispatchers.Default.limitedParallelism(1))
.build()
+ }
}
\ 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 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..7666884 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
@@ -43,6 +47,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.onPreviewKeyEvent
@@ -93,15 +99,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,72 +168,127 @@ private fun InfoColumn(
.distinct()
.toList()
- val firstLinkFocus = remember { FocusRequester() }
- LaunchedEffect(links) {
- if (links.isNotEmpty()) {
- kotlinx.coroutines.delay(150)
- runCatching { firstLinkFocus.requestFocus() }
+ 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)
}
- LazyColumn(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),
- )
- }
+ val descFocus = remember { FocusRequester() }
+ // The description owns the focus for the whole panel: UP/DOWN scroll it and are
+ // always consumed, so scrolling can never strand focus somewhere it can't come
+ // back from (issue #23 review: "it loses focus and it's impossible to scroll up").
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(150)
+ runCatching { descFocus.requestFocus() }
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ if (description.isBlank()) {
+ SidebarFallback("No description.", onDismiss)
+ } else {
+ DescriptionScroll(
+ annotated = annotated,
+ focusRequester = descFocus,
+ onDismiss = onDismiss,
+ // Links stay inline in the running text (issue #23 review) instead of
+ // being repeated in a list underneath; OK opens the first one.
+ onActivate = { links.firstOrNull()?.let { open(it) } },
+ 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,
- )
+ }
+}
+
+/**
+ * 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,
+ onActivate: () -> Unit,
+ 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
+ // One press moves a third of a screenful, so consecutive stops
+ // overlap and every line of a long description gets shown (issue
+ // #23 review: the old 85% step jumped past the middle of the text
+ // on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN).
+ val step = (viewportPx * 0.34f).coerceAtLeast(150f)
+ val code = ke.nativeKeyEvent.keyCode
+ // Match Compose's normalised key AND the raw keycode: remotes don't all
+ // report the same native codes, and matching only the raw one left UP
+ // unhandled on some devices (issue #23 review).
+ val isUp = ke.key == Key.DirectionUp || code == KeyEvent.KEYCODE_DPAD_UP
+ val isDown = ke.key == Key.DirectionDown || code == KeyEvent.KEYCODE_DPAD_DOWN
+ val isLeft = ke.key == Key.DirectionLeft || code == KeyEvent.KEYCODE_DPAD_LEFT
+ val isCenter = ke.key == Key.DirectionCenter || ke.key == Key.Enter ||
+ code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER
+ when {
+ // UP/DOWN are always consumed and always attempt the scroll (it
+ // clamps at the ends by itself), so focus stays here and the text
+ // can always be scrolled back up.
+ isDown -> {
+ scope.launch { scroll.animateScrollBy(step) }
+ true
}
+ isUp -> {
+ scope.launch { scroll.animateScrollBy(-step) }
+ true
+ }
+ isLeft -> {
+ onDismiss(); true
+ }
+ isCenter -> {
+ onActivate(); 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),
+ )
}
}
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..1de2c73 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
@@ -2,6 +2,8 @@ package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -37,6 +39,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,19 +66,18 @@ 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
/**
* Channels, SmartTube-style: one row per LOCAL user.
*
- * - Row 1: the logged-in user ("'s Channels"), then the other local
- * users sorted by videos / subscribers / plays (desc).
- * - Each row: that user's channels, same ordering; channels with no videos simply
- * stay inside their user's row.
+ * - Row 1: the logged-in user ("'s Channels") — only when they own
+ * at least one channel WITH videos; then the other local users by total videos.
+ * - Channels with no videos never render (watching-only accounts), at any level.
* - Followed-instance channels at the end, grouped per platform.
*
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
@@ -93,10 +95,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,26 +124,21 @@ 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.
+ /** Guards against a second pass while the first is still running (issue #32:
+ * the load now starts at app launch, so opening the view can overlap it). */
+ private var inFlight = false
fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window:
// repaint the in-memory rows and skip the network (issue #13) — rows are
// never torn down on re-entry; an account switch builds a new ViewModel.
+ if (inFlight) return
if (!force && userRows.isNotEmpty() && fresh()) return
+ inFlight = true
loading = true
error = null
viewModelScope.launch {
@@ -174,6 +167,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
error = e.message ?: "Could not load channels"
} finally {
loading = false
+ inFlight = false
}
}
}
@@ -224,11 +218,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)
}
@@ -280,14 +269,24 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
}
private fun rebuildRowsInner(host: String?) {
- val locals = allChannels.filter { it.host == host }
+ // Channels without videos don't exist on this screen: plenty of local
+ // accounts are watching-only and their empty channels are pure noise if
+ // rendered (user rule). This also covers the own row — it only renders
+ // when the logged-in user has at least one channel with videos, and the
+ // group sort below still puts it first in that case.
+ val locals = allChannels
+ .filter { it.host == host && statOf(it) > 0 }
val ownNames = userChannels.map { c -> c.name }.toSet()
val byOwner = locals.groupBy { ownerKey(it) }
val groups = byOwner.values.map { row ->
val sorted = row.sortedWith(
+ // Stable keys only: all of these come from the video listing and
+ // never change mid-load — subscriber counts arrive later per-
+ // channel and must not reorder anything while loading (rows that
+ // jump while stats stream in read as broken).
compareByDescending { statOf(it) }
- .thenByDescending { it.followersCount }
- .thenByDescending { playsOf(it) },
+ .thenByDescending { playsOf(it) }
+ .thenByDescending { it.followersCount },
)
val own = row.firstOrNull { it.name in ownNames }
UserGroup(
@@ -300,14 +299,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
userRows = groups.sortedWith(
compareByDescending { g -> g.channels.any { it.name in ownNames } }
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
+ .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.plays ?: 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 ->
@@ -335,6 +329,8 @@ fun ChannelsScreen(
epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
+ /** Channel opened last — gets the focus back on return (issue #34). */
+ focusHandle: String? = null,
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) {
LaunchedEffect(epoch) { viewModel.load() }
@@ -342,21 +338,18 @@ fun ChannelsScreen(
when {
viewModel.loading && viewModel.userRows.isEmpty() ->
- // Placeholder rows while the channels load (issue #19): header stub
- // + a row of shimmer card-shaped placeholders, twice.
+ // One row header stub + a single loading card, not a fake grid (#31).
androidx.compose.foundation.layout.Column {
- repeat(2) {
- Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
- Box(
- Modifier
- .width(180.dp)
- .height(22.dp)
- .clip(CircleShape)
- .background(Color(0xFF232323)),
- )
- }
- ShimmerRow()
+ Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
+ Box(
+ Modifier
+ .width(180.dp)
+ .height(22.dp)
+ .clip(CircleShape)
+ .background(Color(0xFF232323)),
+ )
}
+ ShimmerRow(size = 1)
}
viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -377,6 +370,14 @@ fun ChannelsScreen(
// as stats stream in the rows re-sort, and re-focusing their first card
// would steal focus mid-navigation (user: "up jumps to the first row").
val focusHandedOut = remember { mutableStateOf(false) }
+ // The channel we came back from only wins the focus while it is still
+ // in the rows. When it isn't (renamed, unsubscribed, or the rows simply
+ // never carried it) every row used to ask for no focus at all — "when I
+ // click on Channels, none is selected" (#32 review). Fall back to the
+ // first row in that case, like the video cards do.
+ val focusRowExists = focusHandle != null && viewModel.userRows.any {
+ group -> group.channels.any { it.name + "@" + it.host == focusHandle }
+ }
LazyColumn(Modifier.fillMaxSize()) {
viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) {
@@ -386,13 +387,22 @@ fun ChannelsScreen(
)
}
item(key = "row-" + group.ownerName) {
+ // Coming back from a channel page, focus the card we opened
+ // instead of the very first one (issue #34).
+ val target = group.channels.indexOfFirst {
+ it.name + "@" + it.host == focusHandle
+ }
+ val isTargetRow = target >= 0
ChannelRow(
group.channels,
onOpenChannel,
onOpenDrawer,
drawerPulse = drawerPulse,
- focused = !focusHandedOut.value &&
- group == viewModel.userRows.firstOrNull(),
+ focused = !focusHandedOut.value && (
+ if (focusRowExists) isTargetRow
+ else group == viewModel.userRows.firstOrNull()
+ ),
+ focusIndex = if (isTargetRow) target else 0,
onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] },
@@ -439,6 +449,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,
@@ -446,6 +457,8 @@ private fun ChannelRow(
onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0,
focused: Boolean,
+ /** Which card to hand the initial focus to (issue #34). */
+ focusIndex: Int = 0,
onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?,
@@ -468,19 +481,39 @@ private fun ChannelRow(
val notFullyVisible = target == null ||
target.offset < 0 ||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset
- if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
+ if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
}
}
if (focused) {
- LaunchedEffect(channels.isNotEmpty()) {
+ LaunchedEffect(channels.isNotEmpty(), focusIndex) {
if (channels.isNotEmpty()) {
- if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
+ val target = focusIndex.coerceIn(0, channels.size - 1)
+ // Bring it on screen first; a card further right isn't composed yet,
+ // so retry until its focus requester is attached (issue #34).
+ if (target > 0) runCatching { rowState.scrollToItem(target) }
+ repeat(8) {
+ delay(80)
+ val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull()
+ if (ok != null) {
+ onFocused()
+ return@LaunchedEffect
+ }
+ }
+ // The wanted card never became focusable (its row scrolled past it,
+ // or the handle went stale): take the first card instead, so the
+ // screen is never left with nothing selected (#32 review).
+ val first = focusRequesters[0]
+ if (first != null && runCatching { first.requestFocus() }.isSuccess) onFocused()
}
}
}
LazyRow(
state = rowState,
+ // Land on the last-focused card when moving between rows (issue #29). No
+ // custom fallback: a requester whose card has scrolled out of composition
+ // crashed with "FocusRequester is not initialized".
+ modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -553,13 +586,19 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
- AsyncImage(
- model = coil.request.ImageRequest.Builder(
- androidx.compose.ui.platform.LocalContext.current,
- )
+ // The request must be remembered: ImageRequest has no value equality,
+ // a fresh request per recomposition reset the shimmer (and retried
+ // the load) every frame — the fps regression on the TV.
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host }
+ val coverReq = remember(coverKey, coverUrl) {
+ coil.request.ImageRequest.Builder(ctx)
.data(coverUrl)
.size(480, 270) // same key as prefetch
- .build(),
+ .build()
+ }
+ ShimmerAsyncImage(
+ model = coverReq,
contentDescription = channel.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
@@ -567,21 +606,26 @@ fun ChannelCard(
}
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
- AsyncImage(
- model = coil.request.ImageRequest.Builder(
- androidx.compose.ui.platform.LocalContext.current,
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
+ val avatarReq = remember(avatarUrl) {
+ coil.request.ImageRequest.Builder(
+ ctx,
)
- .data(absoluteAvatar(avatar))
+ .data(avatarUrl)
.size(80, 80)
- .build(),
+ .build()
+ }
+ ShimmerAsyncImage(
+ model = avatarReq,
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..53d8625 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
@@ -1,5 +1,7 @@
package net.jeena.jtubetv.ui.screens
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -9,11 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
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 +44,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 +60,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 +76,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 +104,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 +159,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 +167,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 +175,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 +185,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
+ subsLoadingMore = false
}
}
@@ -210,12 +197,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 +216,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 +275,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 +300,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 +320,7 @@ fun HomeRowsScreen(
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = recentItems,
@@ -367,6 +330,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 +349,7 @@ fun HomeRowsScreen(
}
item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = viewModel.subItems,
@@ -395,6 +359,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 +389,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 +408,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()
@@ -484,11 +453,17 @@ fun FocusScrollRow(
val notFullyVisible = target == null ||
target.offset < 0 ||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
- if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
+ if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
}
}
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. No custom fallback —
+ // handing back a requester whose card has scrolled out of composition
+ // crashed with "FocusRequester is not initialized".
+ modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -512,5 +487,30 @@ fun FocusScrollRow(
)
afterItem(index, video)
}
+ if (trailingLoading) {
+ item(key = "trailing-loading") {
+ ShimmerCard(
+ modifier = Modifier
+ .padding(horizontal = 4.dp)
+ .width(rowCardWidth()),
+ )
+ }
+ }
}
}
+
+/**
+ * Eased scroll to [index] with a capped duration (perf): [animateScrollToItem]'s
+ * default spec keeps re-ticking for hundreds of ms on long distances, and every
+ * tick is a full-window redraw on a TV GPU — which is what dragged the scroll
+ * down to a few fps. A short tween keeps the eased feel at a fraction of the
+ * ticks. (Shared by the home rows and the channels rows.)
+ */
+suspend fun LazyListState.easedScrollToItem(index: Int) {
+ val info = layoutInfo
+ val first = info.visibleItemsInfo.firstOrNull() ?: return
+ val stride = first.size + info.mainAxisItemSpacing
+ if (stride <= 0) return
+ val delta = index * stride - (first.index * stride + firstVisibleItemScrollOffset)
+ animateScrollBy(delta.toFloat(), tween(140))
+}
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..1e28d40 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
@@ -87,10 +87,22 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf(null) } // handle
var channelName by rememberSaveable { mutableStateOf(null) } // display name
+ // Last channel opened from the Channels screen — refocused on return (#34).
+ var lastOpenedChannel by rememberSaveable { mutableStateOf(null) }
+ // Channels are expensive to assemble, so start that work at app launch rather
+ // than when the view is first opened (issue #32); the view then usually finds
+ // the rows already there, and load() no-ops while fresh.
+ val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
+ key = "channels$epoch",
+ factory = ChannelsViewModel.factory,
+ )
+ LaunchedEffect(epoch) { channelsViewModel.load() }
// NOT saveable: launching the app should never resume a half-watched player session.
var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
var playingVideoHost by rememberSaveable { mutableStateOf(null) }
+ // Thumbnail of the opened card — shown as the player's poster while it loads.
+ var playingVideoThumb by rememberSaveable { mutableStateOf(null) }
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
var drawerOpen by rememberSaveable { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current
@@ -114,19 +126,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 +142,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 +167,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
},
@@ -169,6 +187,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
val host = video.channel.host
playingVideoHost = if (host != null && host != localHost) host else null
+ playingVideoThumb = video.thumbnailUrl
+ ?: video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { AppGraph.repository.absoluteUrl(it) }
playingVideo = video.shortUUID
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -210,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
drawerPulse = drawerPulse,
)
Destination.Channels -> ChannelsScreen(
- onOpenChannel = { handle -> channelDetails = handle },
+ onOpenChannel = { handle ->
+ channelDetails = handle
+ lastOpenedChannel = handle
+ },
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse,
+ // Back from a channel page re-selects it (issue #34).
+ focusHandle = lastOpenedChannel,
+ viewModel = channelsViewModel,
)
Destination.Search ->
VideoListScreen(
@@ -267,13 +293,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
PlayerScreen(
shortUuid = playingVideo!!,
sourceHost = playingVideoHost,
+ posterUrl = playingVideoThumb,
onBack = { playingVideo = null },
onOpenChannel = { handle, displayName ->
playingVideo = null
channelDetails = handle
channelName = displayName
},
- onOpenVideo = { playingVideo = it; playingVideoHost = null },
+ // A link-opened video has no card thumbnail to pose with.
+ onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null },
)
}
}
diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt
index 3ba847b..310584c 100644
--- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt
+++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt
@@ -207,6 +207,9 @@ fun PlayerScreen(
onBack: () -> Unit,
/** Set when the video lives on another instance (vidiverse search results). */
sourceHost: String? = null,
+ /** Thumbnail of the opened card, shown full-screen while the metadata loads so
+ * the player doesn't flash black before buffering starts (user report). */
+ posterUrl: String? = null,
// From the info sidebar's local PeerTube video links: switches the player over.
onOpenVideo: (String) -> Unit = {},
// From the paused/controls overlay's channel icon: (handle, displayName),
@@ -227,15 +230,27 @@ fun PlayerScreen(
}
when (val s = state) {
- is PlayerUiState.Loading -> PlayerLoading()
+ is PlayerUiState.Loading -> PlayerLoading(posterUrl)
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
}
}
@Composable
-private fun PlayerLoading() {
+private fun PlayerLoading(posterUrl: String? = null) {
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
+ // Show the card's thumbnail (dimmed) while the metadata resolves, so the
+ // player opens onto the video's own poster instead of plain black; it
+ // hands off seamlessly to the buffering thumbnail once the stream is ready.
+ if (posterUrl != null) {
+ coil.compose.AsyncImage(
+ model = posterUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ Box(Modifier.fillMaxSize().background(Color(0xB3000000)))
+ }
CircularProgressIndicator()
}
}
@@ -308,6 +323,10 @@ private fun PlayerSurface(
// 200 ms: quick flaps cancel the pending change and the overlay stays
// composed, so the animation never restarts mid-playback.
var bufferingPending by remember { mutableStateOf(true) }
+ // STATE_READY can fire before the first frame is actually drawn, leaving a black
+ // gap between the thumbnail and the picture. Keep the thumbnail until the first
+ // frame really renders (user report: "thumbnail, then black, then playing").
+ var firstFrame by remember { mutableStateOf(false) }
LaunchedEffect(bufferingPending) {
kotlinx.coroutines.delay(200)
buffering = bufferingPending
@@ -419,7 +438,14 @@ private fun PlayerSurface(
}
override fun onPlaybackStateChanged(playbackState: Int) {
- bufferingPending = playbackState != Player.STATE_READY
+ // Only real buffering shows the overlay (issue #33): STATE_ENDED used
+ // to count as "not ready" and brought the thumbnail + spinner back
+ // over the last frame. The initial load is covered by !firstFrame.
+ bufferingPending = playbackState == Player.STATE_BUFFERING
+ }
+
+ override fun onRenderedFirstFrame() {
+ firstFrame = true
}
})
onDispose {
@@ -573,7 +599,7 @@ private fun PlayerSurface(
// ready to render — metadata resolution alone (the Loading state) is not
// enough (issue #8). Fullscreen loading shows the video's own thumbnail
// dimmed under the circle loader (issue #19) instead of plain black.
- if (buffering) {
+ if (buffering || !firstFrame) {
Box(
Modifier
.fillMaxSize()
@@ -620,9 +646,9 @@ private fun PlayerSurface(
closeSidebar()
},
onOpenVideo = onOpenVideo,
- modifier = Modifier
- .align(Alignment.CenterEnd)
- .padding(top = 48.dp),
+ // Full height from the very top — the old top padding left a black
+ // strip above the panel (issues #23/#24).
+ modifier = Modifier.align(Alignment.TopEnd),
)
}
}
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..e910f5b 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
@@ -124,12 +124,19 @@ fun VideoCard(
.background(Color(0xFF222222)),
) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
- val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
- // search-index results carry an absolute URL for their own instance
- .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
- .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
- .build()
- AsyncImage(
+ // The request object must be remembered: ImageRequest has no value
+ // equality, and a fresh request per recomposition reset the
+ // shimmer's loaded state (and retried the load) on every frame —
+ // the fps regression on the TV.
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ val req = remember(video.id, video.thumbnailUrl) {
+ coil.request.ImageRequest.Builder(ctx)
+ // search-index results carry an absolute URL for their own instance
+ .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
+ .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
+ .build()
+ }
+ ShimmerAsyncImage(
model = req,
contentDescription = video.name,
contentScale = ContentScale.Crop,
@@ -320,6 +327,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..8e06582 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,12 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+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
@@ -70,10 +74,42 @@ fun VideoListScreen(
),
) {
val items = viewModel.items
- // per-item focus requesters so BACK from the player can refocus the opened card
- val requesters = androidx.compose.runtime.remember(items.size, epoch) {
- androidx.compose.runtime.mutableStateMapOf()
+ // per-item focus requesters so BACK from the player can refocus the opened card.
+ // A plain HashMap, like the rows: this is/was written during composition of
+ // every card, and a snapshot map here re-invalidated the reading scopes on
+ // each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5).
+ // Keyed only on [epoch]: on items.size the map was dropped wholesale, and
+ // already-composed cards didn't re-populate it until their next recompose.
+ val requesters = androidx.compose.runtime.remember(epoch) {
+ HashMap()
}
+ // Scroll position survives destination switches (per source), not just player exits.
+ val gridState = androidx.compose.runtime.saveable.rememberSaveable(
+ inputs = arrayOf(epoch),
+ key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
+ ) { androidx.compose.foundation.lazy.grid.LazyGridState() }
+
+ if (source is ListSource.Channel) {
+ // Fetch the banner/avatar/counts for the channel page header.
+ LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
+ // Park the grid at the top as soon as the page has content, BEFORE the
+ // first card takes focus (the effect below). It used to run after it:
+ // handing the first card the focus animates the grid up (a focused card
+ // that sits partly off-screen is pulled into view), and this effect then
+ // snapped it back down — the jump that was still reported on the channel
+ // page when the first video got focus (#27 review).
+ // The park has to be unconditional, and has to reset the row's offset,
+ // not just its index: the focus pull leaves the header row at index 0 but
+ // slid up by ~96 dp, so a guard on firstVisibleItemIndex > 0 never fires
+ // in that state and the top of the channel header stays clipped off
+ // screen (#31 review). scrollToItem(0) re-anchors both.
+ LaunchedEffect(items.isNotEmpty(), source) {
+ if (items.isNotEmpty()) {
+ runCatching { gridState.scrollToItem(0) }
+ }
+ }
+ }
+
// Pull initial focus out of the sidebar rail and into the content grid.
val initialFocus = remember { FocusRequester() }
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
@@ -82,6 +118,9 @@ fun VideoListScreen(
// With a running search the first RESULT gets focus; without one the
// field does (the SearchHeader's own effect) — never both (issue #20).
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
+ // The channel page parks its grid at the top just above; wait for that
+ // beat so the focus handover can never leave a scroll to undo (#27).
+ if (source is ListSource.Channel) kotlinx.coroutines.delay(250)
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
}
}
@@ -96,17 +135,6 @@ fun VideoListScreen(
lastOpenedIndex = -1
}
}
- // Scroll position survives destination switches (per source), not just player exits.
- val gridState = androidx.compose.runtime.saveable.rememberSaveable(
- inputs = arrayOf(epoch),
- key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
- ) { androidx.compose.foundation.lazy.grid.LazyGridState() }
-
- if (source is ListSource.Channel) {
- // Fetch the banner/avatar/counts for the channel page header.
- LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
- }
-
// Row starts of the grid: LEFT from a row's first card opens the drawer
// (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with
// zero spacing, and the content pane eats 40dp (8 start + 32 end), so the
@@ -165,16 +193,21 @@ fun VideoListScreen(
state = gridState,
modifier = Modifier.fillMaxSize().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 -> {}
+ // Page headers scroll WITH the content (issue #27 review: a pinned header
+ // stayed put and the videos slid behind it). The header is compact enough
+ // that it's still fully visible when the first card takes focus.
+ if (source is ListSource.Search || source is ListSource.Channel) {
+ 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,
+ handle = source.handle,
+ onLeftEdge = onOpenDrawer,
+ )
+ else -> {}
+ }
}
}
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@@ -199,21 +232,14 @@ fun VideoListScreen(
// Placeholder cards while the page's first chunk loads (issue #19):
// shaped like the real cards, with the faint shimmer, on every
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
- item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
+ item(key = "initial-placeholders") {
LaunchedEffect(source) { viewModel.loadMore(force = true) }
- androidx.compose.foundation.layout.Column {
- repeat(2) {
- androidx.compose.foundation.layout.Row {
- repeat(cardsPerRow) {
- ShimmerCard(
- modifier = Modifier
- .padding(6.dp)
- .width(rowCardWidth()),
- )
- }
- }
- }
- }
+ // A single loading card, not a full fake grid (issue #31).
+ ShimmerCard(
+ modifier = Modifier
+ .padding(6.dp)
+ .width(rowCardWidth() - 12.dp),
+ )
}
}
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
@@ -239,18 +265,14 @@ 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),
+ )
}
}
}
@@ -396,96 +418,259 @@ 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 scrolls with the video grid.
+ *
+ * Its height is the same before and after the channel loads, so the cards never
+ * get pushed down when it arrives (issue #27 review), and a channel with no
+ * banner gets a colour gradient derived from its handle instead of flat black.
+ */
@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() }
+ val accent = remember(handle) { channelAccent(handle) }
+ 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: 116 dp, with the identity + stats anchored to its bottom; the
+ // description sits in the black strip below it. The whole band is 160 dp
+ // (4 + 116 + 40) — under one card row — so the grid never reflows around it
+ // and the band stays fully on screen when the first card takes focus (#31).
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(116.dp)
+ .background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
+ ) {
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). Only once the channel loaded;
+ // the box above keeps the height reserved in the meantime.
+ if (channelHeader != null) {
+ 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),
+ )
+ } else {
+ // A channel with no icon gets a stand-in, not a gap (#27 review).
+ Box(
+ Modifier
+ .size(56.dp)
+ .clip(androidx.compose.foundation.shape.CircleShape)
+ .background(Color(0x40FFFFFF)),
+ contentAlignment = Alignment.Center,
+ ) {
+ androidx.compose.material3.Icon(
+ imageVector = Icons.Filled.AccountCircle,
+ contentDescription = null,
+ tint = Color(0xFFE8E8E8),
+ modifier = Modifier.size(42.dp),
+ )
+ }
+ }
+ 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),
- )
+ // 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),
+ )
}
- channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
+ }
+ // Description strip in the black beneath the banner. Fixed height so the
+ // header measures the same before and after the channel loads (#27 review).
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(40.dp)
+ .padding(start = 16.dp, end = 16.dp, top = 6.dp),
+ ) {
+ if (description != null) {
Text(
text = description,
- color = Color(0xFFB8B8B8),
+ color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodySmall,
- maxLines = 3,
+ maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
)
}
}
+ }
+}
+
+/**
+ * Deterministic backdrop colours for a channel without a banner (#27 review):
+ * the handle's hash picks a hue, so every channel keeps the same recognisable
+ * gradient instead of a flat black header.
+ */
+private fun channelAccent(handle: String): List {
+ val hue = ((handle.hashCode() ushr 8) % 360 + 360) % 360
+ return listOf(
+ Color.hsv(hue.toFloat(), 0.45f, 0.42f),
+ Color.hsv(((hue + 38) % 360).toFloat(), 0.55f, 0.16f),
+ )
+}
+
+/**
+ * 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,
+ )
+ }
}
@Composable
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/channel.png b/docs/screenshots/channel.png
new file mode 100644
index 0000000..d860148
Binary files /dev/null and b/docs/screenshots/channel.png differ
diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png
index 11d2afe..230212d 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..986b103 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..8ddd367 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..251714c 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..5069216 100644
Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ
diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh
index fdb9af7..a4a81d7 100755
--- a/scripts/start-emulator.sh
+++ b/scripts/start-emulator.sh
@@ -15,6 +15,14 @@
set -euo pipefail
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
+# Prefer the working host SDK at /opt/android-sdk: the emulator that
+# ~/Android/Sdk auto-updated to on Oct 11 (37.2.12) fails its own KVM probe
+# ("/dev/kvm is not found") with KVM perfectly healthy — /opt/android-sdk and
+# the container-home copy both pass -accel-check on the same kernel, so that
+# install is broken. /opt/android-sdk is the documented host SDK anyway.
+if [ -z "${ANDROID_SDK_ROOT:-}" ] && [ -x /opt/android-sdk/emulator/emulator ]; then
+ SDK=/opt/android-sdk
+fi
EMU="$SDK/emulator/emulator"
ADB="$SDK/platform-tools/adb"
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
@@ -76,6 +84,41 @@ if [ -e /dev/kvm ]; then
echo "/dev/kvm found — hardware acceleration on"
fi
+# Pin the emulator process to the integrated GPU so the discrete card stays free
+# for AI inference (windowed mode uses the host GL/Vulkan stack; headless runs on
+# swiftshader — pure software — and never touches any GPU at all). Discovered
+# from the PCI tree: first display device NOT driven by the nvidia driver.
+IGPU=""
+for dev in /sys/bus/pci/devices/*; do
+ class=$(cat "$dev/class" 2>/dev/null) || continue
+ case "$class" in
+ 0x0300*|0x0380*) ;; # VGA / display controller
+ *) continue ;;
+ esac
+ if [ "$(basename "$(readlink "$dev/driver" 2>/dev/null)")" = nvidia ]; then continue; fi
+ IGPU=$(basename "$dev")
+ break
+done
+if [ -n "$IGPU" ]; then
+ # Mesa GL/GLES: force the Mesa vendors, then the Intel render device.
+ export __GLX_VENDOR_LIBRARY_NAME=mesa
+ export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json
+ export DRI_PRIME="pci-${IGPU//./_}"
+ # Vulkan: restrict the loader to the iGPU's ICD (both var names — loader
+ # version differences).
+ for icd in /usr/share/vulkan/icd.d/intel_icd.x86_64.json \
+ /usr/share/vulkan/icd.d/Intel_icd.x86_64.json; do
+ if [ -f "$icd" ]; then
+ export VK_ICD_FILENAMES="$icd"
+ export VK_DRIVER_FILES="$icd"
+ break
+ fi
+ done
+ echo "GPU pinned to iGPU $IGPU (Mesa/Intel env) — dGPU untouched"
+else
+ echo "no iGPU found — leaving GPU selection to the driver (dGPU may be used)"
+fi
+
# --- build combined adb vendor-keys file ------------------------------------
# The TV system image is a user build: adb push to /data/misc/adb/adb_keys is
# blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead.