From 6307addfb8a02a4391ebf26036215e7b39b56a34 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:17:54 +0000
Subject: [PATCH 01/58] player: title header + scrollable description in
info/comments (#23, #24)
The info and comments side panels showed a generic "Info"/"Comments" label,
which read as an empty black bar with no clue which video it belonged to. Both
now show the video's title instead (#24, and the same complaint in #23).
The info panel's description also could not be scrolled with the D-pad: a plain
Text in a LazyColumn takes no focus, so a long description was unreadable past
the first screenful unless it happened to contain links. It is now a focusable,
D-pad-scrollable box; Down at the bottom hands focus to the link list, Left
closes the panel (#23).
---
.../jeena/jtubetv/ui/player/PlayerSidebar.kt | 201 ++++++++++++------
1 file changed, 140 insertions(+), 61 deletions(-)
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)
From d2b40d5f10e4d7d44c306fecb1ec9f917adad553 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:17:54 +0000
Subject: [PATCH 02/58] player: title header + scrollable description in
info/comments (#23, #24)
The info and comments side panels showed a generic "Info"/"Comments" label,
which read as an empty black bar with no clue which video it belonged to. Both
now show the video's title instead (#24, and the same complaint in #23).
The info panel's description also could not be scrolled with the D-pad: a plain
Text in a LazyColumn takes no focus, so a long description was unreadable past
the first screenful unless it happened to contain links. It is now a focusable,
D-pad-scrollable box; Down at the bottom hands focus to the link list, Left
closes the panel (#23).
Co-Authored-By: Claude Opus 4.8
---
.../jeena/jtubetv/ui/player/PlayerSidebar.kt | 201 ++++++++++++------
1 file changed, 140 insertions(+), 61 deletions(-)
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)
From 349c1aa447c217e8f18688f766a24b9ded8f6444 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:18:00 +0000
Subject: [PATCH 03/58] home: Back opens the sidebar from a list view (#26)
Reaching the sidebar used to mean scrolling all the way left to the first card.
Back from any list view now opens it directly; from the open sidebar on Home a
double Back still exits, and Esc (keyboard/scrcpy) mirrors the same model.
---
.../jeena/jtubetv/ui/screens/HomeScreen.kt | 32 +++++++++++--------
1 file changed, 19 insertions(+), 13 deletions(-)
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
},
From a6d0f9ec6ceabbb8331d3b9ea90b5402ca258580 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:18:00 +0000
Subject: [PATCH 04/58] home: Back opens the sidebar from a list view (#26)
Reaching the sidebar used to mean scrolling all the way left to the first card.
Back from any list view now opens it directly; from the open sidebar on Home a
double Back still exits, and Esc (keyboard/scrcpy) mirrors the same model.
Co-Authored-By: Claude Opus 4.8
---
.../jeena/jtubetv/ui/screens/HomeScreen.kt | 32 +++++++++++--------
1 file changed, 19 insertions(+), 13 deletions(-)
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
},
From 8eeffe834d2204a0c55a498fba0f863e4364fc68 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:22:27 +0000
Subject: [PATCH 05/58] rows: restore the last-focused card when moving between
rows (#29)
Moving up/down between the Home rows (and the Channels rows) jumped to the
geometrically-nearest card, so the row shifted left/right and felt inconsistent.
A focusRestorer on each row now lands focus on the card that row last had, so
the rows stay put as you move between them.
---
.../java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt | 4 ++++
app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt | 6 ++++++
2 files changed, 10 insertions(+)
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..603a561 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
@@ -439,6 +440,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 +483,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),
) {
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..83007c0 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
@@ -424,6 +425,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,
@@ -489,6 +491,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),
) {
From e485caa855028b70c6b9fed15ef4b61ef02f134c Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:22:27 +0000
Subject: [PATCH 06/58] rows: restore the last-focused card when moving between
rows (#29)
Moving up/down between the Home rows (and the Channels rows) jumped to the
geometrically-nearest card, so the row shifted left/right and felt inconsistent.
A focusRestorer on each row now lands focus on the card that row last had, so
the rows stay put as you move between them.
Co-Authored-By: Claude Opus 4.8
---
.../java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt | 4 ++++
app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt | 6 ++++++
2 files changed, 10 insertions(+)
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..603a561 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
@@ -439,6 +440,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 +483,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),
) {
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..83007c0 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
@@ -424,6 +425,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,
@@ -489,6 +491,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),
) {
From aea649eab9246650dc84ab75a4f595f900e0ab7d Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:03 +0000
Subject: [PATCH 07/58] loading: drop the snapshot cache, one loading card per
thing (#25)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The saved-list snapshot (issue #9) painted the last session's rows instantly,
then the network refresh swapped them in and the tiles jumped as newer videos
appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and
the Channels screen; images stay fast because Coil already disk-caches them.
Screens now show the loading card while the first page loads instead of stale
content: one card per Home row (not a fake full row), a single card at the end
of a growing grid, and a trailing card when a Home row loads its next page.
---
.../jtubetv/ui/list/VideoListViewModel.kt | 60 +-----------
.../jtubetv/ui/screens/ChannelsScreen.kt | 32 +------
.../net/jeena/jtubetv/ui/screens/HomeRows.kt | 91 +++++++------------
.../ui/screens/list/VideoListScreen.kt | 21 ++---
4 files changed, 48 insertions(+), 156 deletions(-)
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/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt
index 603a561..4cd1c2d 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
@@ -94,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())
@@ -127,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:
@@ -225,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)
}
@@ -303,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 ->
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 83007c0..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
@@ -41,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
@@ -58,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)
@@ -76,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 {
@@ -116,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)
- }
- }
}
}
@@ -177,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()) {
@@ -184,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
}
}
@@ -191,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
@@ -200,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
+ subsLoadingMore = false
}
}
@@ -211,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)
- }
- }
}
}
@@ -236,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> =
@@ -315,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(
@@ -339,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) {
@@ -358,7 +317,7 @@ fun HomeRowsScreen(
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = recentItems,
@@ -368,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() }
@@ -386,7 +346,7 @@ fun HomeRowsScreen(
}
item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = viewModel.subItems,
@@ -396,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() }
@@ -444,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()
@@ -518,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/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt
index 571e02f..6e9f037 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
@@ -239,21 +239,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
From 4176edfd6998fbca67117406076b91a088445ef5 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:03 +0000
Subject: [PATCH 08/58] loading: drop the snapshot cache, one loading card per
thing (#25)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The saved-list snapshot (issue #9) painted the last session's rows instantly,
then the network refresh swapped them in and the tiles jumped as newer videos
appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and
the Channels screen; images stay fast because Coil already disk-caches them.
Screens now show the loading card while the first page loads instead of stale
content: one card per Home row (not a fake full row), a single card at the end
of a growing grid, and a trailing card when a Home row loads its next page.
Co-Authored-By: Claude Opus 4.8
---
.../jtubetv/ui/list/VideoListViewModel.kt | 60 +-----------
.../jtubetv/ui/screens/ChannelsScreen.kt | 32 +------
.../net/jeena/jtubetv/ui/screens/HomeRows.kt | 91 +++++++------------
.../ui/screens/list/VideoListScreen.kt | 21 ++---
4 files changed, 48 insertions(+), 156 deletions(-)
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/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt
index 603a561..4cd1c2d 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
@@ -94,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())
@@ -127,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:
@@ -225,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)
}
@@ -303,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 ->
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 83007c0..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
@@ -41,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
@@ -58,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)
@@ -76,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 {
@@ -116,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)
- }
- }
}
}
@@ -177,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()) {
@@ -184,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
}
}
@@ -191,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
@@ -200,6 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
}
subsInFlight = false
+ subsLoadingMore = false
}
}
@@ -211,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)
- }
- }
}
}
@@ -236,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> =
@@ -315,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(
@@ -339,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) {
@@ -358,7 +317,7 @@ fun HomeRowsScreen(
item {
if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = recentItems,
@@ -368,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() }
@@ -386,7 +346,7 @@ fun HomeRowsScreen(
}
item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
- ShimmerRow()
+ ShimmerRow(size = 1)
} else {
FocusScrollRow(
videos = viewModel.subItems,
@@ -396,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() }
@@ -444,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()
@@ -518,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/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt
index 571e02f..6e9f037 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
@@ -239,21 +239,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
From b2af6fc1a50b8958e0d39743962b3aa7c324131a Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:20 +0000
Subject: [PATCH 09/58] images: shimmer placeholder while thumbnails and art
load (#28)
Thumbnails, channel banners and avatars popped in from a flat grey rectangle. A
new ShimmerAsyncImage shows the same faint shimmer as the card placeholders
while the image loads, and the sweep disposes once the image (or an error)
arrives. Used for video thumbnails and the channel banners/avatars.
---
.../jtubetv/ui/screens/ChannelsScreen.kt | 10 ++---
.../jtubetv/ui/screens/list/VideoCard.kt | 44 ++++++++++++++++++-
2 files changed, 48 insertions(+), 6 deletions(-)
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 4cd1c2d..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
@@ -64,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
@@ -531,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,
)
@@ -545,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,
)
@@ -554,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/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(
From 84a0527e20ef6e084e588f98cfabc5cea4185ef4 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:20 +0000
Subject: [PATCH 10/58] images: shimmer placeholder while thumbnails and art
load (#28)
Thumbnails, channel banners and avatars popped in from a flat grey rectangle. A
new ShimmerAsyncImage shows the same faint shimmer as the card placeholders
while the image loads, and the sweep disposes once the image (or an error)
arrives. Used for video thumbnails and the channel banners/avatars.
Co-Authored-By: Claude Opus 4.8
---
.../jtubetv/ui/screens/ChannelsScreen.kt | 10 ++---
.../jtubetv/ui/screens/list/VideoCard.kt | 44 ++++++++++++++++++-
2 files changed, 48 insertions(+), 6 deletions(-)
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 4cd1c2d..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
@@ -64,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
@@ -531,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,
)
@@ -545,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,
)
@@ -554,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/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(
From 21d8290f1d51254898e4e10961b2c44c855f6415 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:36 +0000
Subject: [PATCH 11/58] channel: redesign the header, reachable Subscribe
button (#27)
Without a mouse the channel banner was invisible: opening a channel focused the
first video card, which scrolled the header (a grid item) off-screen. The header
is now pinned above the grid and never scrolls away. The banner is its backdrop,
darkened to black at the bottom; the avatar sits beside the title with a
"by on " byline, the subscriber/video/created stats, and the
description flowing into the black beneath (a long one grows the header down
instead of being clipped). A Subscribe button sits on the banner, reachable with
UP from the first card, wired to the same follow/unfollow as the player.
AGENTS.md: subscribing is in scope (already on the player, now the channel page
too), so drop it from the excluded list.
---
AGENTS.md | 9 +-
.../ui/screens/list/VideoListScreen.kt | 299 +++++++++++++-----
2 files changed, 220 insertions(+), 88 deletions(-)
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/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 6e9f037..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) {
@@ -393,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,
+ )
}
}
From 39d3b1218232b1194e825c706c9c09dc83e6e49d Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:23:36 +0000
Subject: [PATCH 12/58] channel: redesign the header, reachable Subscribe
button (#27)
Without a mouse the channel banner was invisible: opening a channel focused the
first video card, which scrolled the header (a grid item) off-screen. The header
is now pinned above the grid and never scrolls away. The banner is its backdrop,
darkened to black at the bottom; the avatar sits beside the title with a
"by on " byline, the subscriber/video/created stats, and the
description flowing into the black beneath (a long one grows the header down
instead of being clipped). A Subscribe button sits on the banner, reachable with
UP from the first card, wired to the same follow/unfollow as the player.
AGENTS.md: subscribing is in scope (already on the player, now the channel page
too), so drop it from the excluded list.
Co-Authored-By: Claude Opus 4.8
---
AGENTS.md | 9 +-
.../ui/screens/list/VideoListScreen.kt | 299 +++++++++++++-----
2 files changed, 220 insertions(+), 88 deletions(-)
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/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 6e9f037..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) {
@@ -393,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,
+ )
}
}
From f2d63fdb1c3540ff9d6bfaa206297d2af5154c17 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:25:25 +0000
Subject: [PATCH 13/58] docs: remove BACKLOG.md
Future work is tracked as Forgejo issues now; the standalone backlog file was
redundant. AGENTS.md and README.md no longer reference it.
---
BACKLOG.md | 85 ------------------------------------------------------
1 file changed, 85 deletions(-)
delete mode 100644 BACKLOG.md
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
From 069dfe38c4e85fd8419d134cfc4be5c1a058d218 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:25:25 +0000
Subject: [PATCH 14/58] docs: remove BACKLOG.md
Future work is tracked as Forgejo issues now; the standalone backlog file was
redundant. AGENTS.md and README.md no longer reference it.
Co-Authored-By: Claude Opus 4.8
---
BACKLOG.md | 85 ------------------------------------------------------
1 file changed, 85 deletions(-)
delete mode 100644 BACKLOG.md
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
From a08f3b956cc31206c8c37334653a19e5398b94e6 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:25:49 +0000
Subject: [PATCH 15/58] docs: rewrite the README for humans, add the PeerTube
logo (#30)
Rewrite the README so it leads with what JTube TV is and who it's for, refresh
the feature list and screenshot captions to match the current UI (three Home
rows, Back opens the sidebar, Subscribe on the channel page), and add the
official PeerTube logo next to the name. Screenshot images are refreshed in a
follow-up.
---
README.md | 88 ++++++++++++++++++++++++++----------------
docs/peertube-logo.svg | 6 +++
2 files changed, 61 insertions(+), 33 deletions(-)
create mode 100644 docs/peertube-logo.svg
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/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 @@
+
From e8117ab08e0d20913cf060b825fee831d57ffdbe Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:25:49 +0000
Subject: [PATCH 16/58] docs: rewrite the README for humans, add the PeerTube
logo (#30)
Rewrite the README so it leads with what JTube TV is and who it's for, refresh
the feature list and screenshot captions to match the current UI (three Home
rows, Back opens the sidebar, Subscribe on the channel page), and add the
official PeerTube logo next to the name. Screenshot images are refreshed in a
follow-up.
Co-Authored-By: Claude Opus 4.8
---
README.md | 88 ++++++++++++++++++++++++++----------------
docs/peertube-logo.svg | 6 +++
2 files changed, 61 insertions(+), 33 deletions(-)
create mode 100644 docs/peertube-logo.svg
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/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 @@
+
From bdf9b35ebd190617e68cbece68ffa31f78faaf85 Mon Sep 17 00:00:00 2001
From: Jeena
Date: Thu, 15 Oct 2026 12:37:51 +0000
Subject: [PATCH 17/58] docs: refresh the README screenshots (#30)
New screenshots of the current UI (title bar with the account on the right, the
redesigned Home with three rows and progress bars, My videos privacy badges, the
Channels rows and the player). Captured against the public PeerTube demo instance
(peertube2.cpy.re) so they show real CC/Blender content, as before.
---
docs/screenshots/channels.png | Bin 189470 -> 585520 bytes
docs/screenshots/drawer.png | Bin 456394 -> 649929 bytes
docs/screenshots/home.png | Bin 626634 -> 847658 bytes
docs/screenshots/myvideos.png | Bin 273189 -> 1155991 bytes
docs/screenshots/player.png | Bin 929935 -> 714426 bytes
5 files changed, 0 insertions(+), 0 deletions(-)
diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png
index 11d2afed041943598677ea6bf428f1f382ccb0c2..f88dc4ea4dbec4adb6d1629d1151beba382d92c9 100644
GIT binary patch
literal 585520
zcmeFYbySqy7dAX#(4YdMNJ*zkhom5#LrH^lH%Oy`h>}A|gD}95N(>DuB3(lajYxxZ
zNxx^FpZYw1eSdyyed~K&xMXCQ`@YZF``XvOu6@GQRpp5ADe)l?2$6!k3={&v35Gx}
z3*lY`|Fb;!SsMI<`-W7CBaV7Cm{~
z=&P+YnY5>NUd4T`@2^N0=x%KA-1agdvZTj!4Wd__}tiLa_
zqW|wN{=dCAuO+?JvhEX%qKL1^w&24zH*NOLs3S!gm_%3h`4f$QDe7xk+uG*9pVZY`
z6a2eV)j8NT9Jf2#VPmFpqulIxmM>w!mAL@#zaavtq~OY`kKu;KrQ-SCF=AUcz<(l}XgS{nvyTv+q5n@X#ZXMB
zKpYgxIS~)&h@vRTap1Z4TL+;kC};rt1-g<85%jg-J)Qd*QzTU73o0c94v$1p5TSIr
z*;W=V#{b?v1StVNhm(V}J@Hk<0c+f=o41Gaa|>(Wt^2b3Z1!E|9_pVG*$Locos1KS
z;)sg9bCH#Gb|oS0D_ZtCb0BdUIQw
zg_pec=&6F61KX;+H0I+XbXs$LhEZ*viHU-GAx(d7$g~3cc`#L5N9`DrYXWJhm&nPX
zIcXIU%Prfq*>xD;lfg1`n4p@!;FEz`@eq_Cg-oElNFfwqxkie)jX1X=Ig(__?(o+_
zFPK;6#!q!F^b^S{;qSBfiu-MplLy`~D{U56X{E=bIFJ8sp}-HJs288Jz!$hQ_H#*r
z`Kh$T2cyV!0a|Y(-4zf0(zQ-cBOe-jP3(X%aIr9lobnHR59kx*B03*IKfzX|p$hE<
zLN;v4`Ci>p29!4vWXWznrbaNcm8i%U%|X9ZSp1vX!925$tYn?j;1N&w^&c(R@Ui#p
z&exSf5%I8&T*B-t{YwslSRWdj*adBrkcS**a0`=n6iHh*63YKRtD%mHUoq5x%bo-&imds2xdDkc1fP`%NDa=ME1
z&HQ~-{(hoPD^E#g0LtXgejyFGK07ns(4gyY3uJexQNfUz7skwSg2_6=zEk^6_x?J3GF#C?j3R=S$+kBw{~3o
z@W*S`e=nh#XvuaXob^Vy!{D2l1Ki8l?Ck7NAu1jESatf?EPlTJw~m5XE~4oOY;eJ0
zyqp(V@Jbf)%ekl+4a;mkA-hTg1l%P@WBkcl-IPe6ieFv&)7l|&inQ+E}g
z{f!2s8rRyUEPd=oMyFMk)@7WRQmu2YwWSkNn%Mn#s*E>|JiXPP8t}lNP4Fk