Compare commits

..

No commits in common. "29dee24849198175db8b76adfcbfe7585c97e367" and "c70d98ac6453315ec59221a8a970ba11439e45d5" have entirely different histories.

9 changed files with 166 additions and 615 deletions

View file

@ -77,9 +77,4 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev
- 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.
subscribing/unsubscribing, deleting, creating channels/users/playlists.

View file

@ -125,6 +125,32 @@ class PeerTubeRepository(
api.serverConfig().instance.name ?: rawHost
}
/**
* videosCount + total plays for a channel (listing DTOs don't carry them).
* Sums views over up to [maxPages] video pages.
*/
suspend fun channelStats(handle: String, maxPages: Int = 4): Result<net.jeena.jtubetv.data.api.ChannelStats> =
runCatching {
var start = 0L
var videos = 0L
var plays = 0L
var total: Long? = null
var latestThumb: String? = null
repeat(maxPages) {
val page = network.api.channelVideos(handle, start, 25)
if (total == null) {
total = page.total
latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path ->
if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/')
}
}
plays += page.data.sumOf { it.views }
start += page.data.size
videos = total ?: videos
}
net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb)
}
private fun unauthenticatedApiFor(base: String): PeerTubeApi =
Retrofit.Builder()
.baseUrl(apiUrlOf(base) + "/")
@ -271,9 +297,6 @@ class PeerTubeRepository(
suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result<Unit> =
runCatching { network.api.removeFromPlaylist(playlistId, elementId) }
suspend fun deletePlaylist(playlistId: Long): Result<Unit> =
runCatching { network.api.deletePlaylist(playlistId) }
suspend fun createPlaylist(displayName: String): Result<VideoPlaylistSummaryDto?> = runCatching {
val created = network.api.createPlaylist(CreatePlaylistBody(displayName))
.videoPlaylist ?: throw IllegalStateException("No playlist in response")

View file

@ -50,9 +50,6 @@ data class VideoDto(
val duration: Int = 0,
@SerialName("publishedAt") val publishedAt: String? = null,
val thumbnailPath: String? = null,
// Present on list responses (absent in detail) — lets the channels screen
// group local channels by owner without paging the channel listing.
val account: ChannelOwnerAccount? = null,
val privacy: Labelled? = null,
val nsfw: Boolean = false,
val views: Long = 0,

View file

@ -150,9 +150,6 @@ interface PeerTubeApi {
@Body body: AddToPlaylistBody,
)
@DELETE("video-playlists/{playlistId}")
suspend fun deletePlaylist(@Path("playlistId") playlistId: Long): Response<Unit>
@DELETE("video-playlists/{playlistId}/videos/{elementId}")
suspend fun removeFromPlaylist(
@Path("playlistId") playlistId: Long,

View file

@ -35,13 +35,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type
@ -50,15 +43,10 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.foundation.border
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
import net.jeena.jtubetv.ui.screens.list.dpadCenterTwoStage
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
import net.jeena.jtubetv.data.api.CommentThreadDto
import net.jeena.jtubetv.data.api.VideoDetailDto
@ -78,13 +66,9 @@ private val Accent = Color(0xFFCE2B2B)
@Composable
fun PlayerSidebar(
mode: String, // "comments" | "playlists" | "info"
mode: String, // "comments" | "playlists"
detail: VideoDetailDto,
onDismiss: () -> Unit,
// PeerTube video links from the info panel's description.
onOpenVideo: (String) -> Unit = {},
// PeerTube channel links (drill down to the channel's page).
onOpenChannel: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(
@ -94,148 +78,15 @@ fun PlayerSidebar(
.background(SidebarBg),
) {
Text(
text = when (mode) {
"playlists" -> "Playlists"
"info" -> "Info"
else -> "Comments"
},
text = if (mode == "playlists") "Playlists" else "Comments",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(20.dp),
)
when (mode) {
"playlists" -> PlaylistsColumn(detail, onDismiss)
"info" -> InfoColumn(detail, onDismiss, onOpenVideo, onOpenChannel)
else -> CommentsColumn(detail, onDismiss)
}
if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss)
}
}
// ---- info (issue #12) ------------------------------------------------------
private val UrlPattern = Regex("https?://[^\\s<>\"()]+")
@Composable
private fun InfoColumn(
detail: VideoDetailDto,
onDismiss: () -> Unit,
onOpenVideo: (String) -> Unit,
onOpenChannel: (String) -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val description = detail.description ?: ""
// PeerTube video/channel URLs stay in the app; everything else goes to the browser.
fun open(url: String) {
val trimmed = url.trimEnd('.', ',', ')')
val uri = runCatching { android.net.Uri.parse(trimmed) }.getOrNull()
if (uri != null) {
val path = uri.path ?: ""
val last = path.split('/').filter { it.isNotBlank() }.lastOrNull() ?: ""
if (path.startsWith("/w/") || path.startsWith("/videos/watch/")) {
if (last.isNotBlank()) {
onOpenVideo(last)
onDismiss()
return
}
}
if (path.startsWith("/video-channels/") || path.startsWith("/c/")) {
if (last.isNotBlank()) {
onOpenChannel(last)
onDismiss()
return
}
}
}
runCatching {
context.startActivity(
android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(trimmed))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
// distinct links in first-appearance order
val links = UrlPattern.findAll(description)
.map { it.value.trimEnd('.', ',', ')') }
.distinct()
.toList()
val firstLinkFocus = remember { FocusRequester() }
LaunchedEffect(links) {
if (links.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { firstLinkFocus.requestFocus() }
}
}
LazyColumn(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
item(key = "desc") {
Text(
text = buildAnnotatedString {
var rest = description
while (true) {
val match = UrlPattern.find(rest) ?: break
append(rest.substringBefore(match.value))
val url = match.value.trimEnd('.', ',', ')')
withLink(
LinkAnnotation.Url(
url,
TextLinkStyles(
SpanStyle(
color = Color(0xFF66B2FF),
textDecoration = TextDecoration.Underline,
),
),
{ _ -> open(url) },
),
) { append(url) }
rest = rest.substringAfter(match.value)
}
append(rest)
},
color = TextMain,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
}
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,
)
}
}
}
}
}
}
/** Link click listener: the annotation callback shape. */
private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)
/** Short label for a link row: host + path (the URL itself, folded). */
private fun linkLabel(url: String): String = url
/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */
@Composable
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
@ -251,17 +102,11 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
.fillMaxWidth()
.padding(20.dp)
.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown) {
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
onDismiss(); true
}
// A one-row panel: no navigation possible — stay put
// instead of letting focus escape to the action row.
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_RIGHT -> true
else -> false
}
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
) {
onDismiss()
true
} else false
}
.focusRequester(fr)
@ -269,23 +114,13 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
)
}
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar; DOWN/UP
* are consumed at the list's first/last row so focus never escapes the panel
* — unless an [onUp] hook moves focus deliberately (e.g. to the text field
* above the playlists list) — the exit-cancel focusProperties clashed with
* the rows' own close-and-move, so edges are handled per row. */
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */
@Composable
private fun SidebarRow(
modifier: Modifier = Modifier,
onLeft: (() -> Unit)? = null,
onUp: (() -> Unit)? = null,
atTop: Boolean = false,
atBottom: Boolean = false,
focusRequester: FocusRequester? = null,
onClick: (() -> Unit)? = null,
// Long press on OK opens a context action (e.g. playlist removal) instead
// of the click — the click itself fires on key release then.
onLongPress: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@ -294,31 +129,19 @@ private fun SidebarRow(
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
if (focusRequester != null) m = m.focusRequester(focusRequester)
m = m.onPreviewKeyEvent { ke ->
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
if (onLeft != null) onLeft()
onLeft != null
}
// No focusable beyond the list's ends in this direction:
// swallowing the key keeps focus inside the panel.
KeyEvent.KEYCODE_DPAD_DOWN -> atBottom
KeyEvent.KEYCODE_DPAD_UP -> {
if (onUp != null) {
onUp()
true
} else atTop
}
else -> false
if (onLeft != null) {
m = m.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
) {
onLeft()
true
} else false
}
}
m = if (onLongPress != null) {
m.dpadCenterTwoStage(onClick ?: {}, onLongPress)
} else {
m.dpadCenterActivatable(onClick ?: {})
}
m = m.focusable()
m = m
.dpadCenterActivatable(onClick ?: {})
.focusable()
if (onClick != null) m = m.clickable(onClick = onClick)
Row(
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
@ -389,8 +212,6 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
CommentRow(
comment = comment,
onLeft = onDismiss,
atTop = index == 0,
atBottom = index == items.size - 1,
focusRequester = if (index == 0) firstRow else null,
)
}
@ -414,16 +235,12 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
private fun CommentRow(
comment: CommentThreadDto,
onLeft: () -> Unit,
atTop: Boolean = false,
atBottom: Boolean = false,
focusRequester: FocusRequester? = null,
) {
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
SidebarRow(
modifier = Modifier.padding(vertical = 4.dp),
onLeft = onLeft,
atTop = atTop,
atBottom = atBottom,
focusRequester = focusRequester,
) {
if (avatar != null) {
@ -466,34 +283,19 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
var containedBy by remember { mutableStateOf<Map<Long, Long>>(emptyMap()) }
// Optimistic flips: shown immediately when toggled, then folded away once
// the server's membership check has settled (a roundtrip can take a
// second — pressing without instant feedback feels broken).
var addedOptimistically by remember { mutableStateOf<Set<Long>>(emptySet()) }
var removedOptimistically by remember { mutableStateOf<Set<Long>>(emptySet()) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
val inputFocus = remember { FocusRequester() }
val rowsFocus = remember { FocusRequester() }
val createBtnFocus = remember { FocusRequester() }
var busy by remember { mutableStateOf(false) }
suspend fun refreshHits() {
repository.playlistsContaining(detail.id).fold(
onSuccess = {
containedBy = it
addedOptimistically = emptySet()
removedOptimistically = emptySet()
},
onSuccess = { containedBy = it },
onFailure = { },
)
}
fun checked(playlistId: Long): Boolean =
(containedBy.containsKey(playlistId) && playlistId !in removedOptimistically) ||
playlistId in addedOptimistically
LaunchedEffect(detail.id) {
repository.myPlaylists().fold(
onSuccess = { playlists = it },
@ -504,28 +306,16 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
}
fun toggle(playlist: VideoPlaylistSummaryDto) {
// One toggle at a time: a double-activated press (some devices fire
// CENTER twice — see PlayerActionButton) or spam clicks could race
// add/remove against stale element ids.
if (busy) return
val inside = checked(playlist.id)
busy = true
val elementId = containedBy[playlist.id]
scope.launch {
if (inside) {
val elementId = containedBy[playlist.id] ?: return@launch
if (repository.removeFromPlaylist(playlist.id, elementId).isSuccess) {
removedOptimistically = removedOptimistically + playlist.id
}
if (elementId != null) {
repository.removeFromPlaylist(playlist.id, elementId)
} else {
addedOptimistically = addedOptimistically + playlist.id
if (repository.addToPlaylist(playlist.id, detail.id).isFailure) {
addedOptimistically = addedOptimistically - playlist.id
}
repository.addToPlaylist(playlist.id, detail.id)
}
// Membership checks settle server-side; re-check after a moment.
kotlinx.coroutines.delay(1000)
refreshHits()
busy = false
}
}
@ -542,27 +332,6 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
}
}
// Long-press on a row asks; confirm deletes via DELETE video-playlists/{id}.
var removeCandidate by remember { mutableStateOf<VideoPlaylistSummaryDto?>(null) }
fun requestRemove(playlist: VideoPlaylistSummaryDto) {
if (!busy) removeCandidate = playlist
}
fun delete(playlist: VideoPlaylistSummaryDto) {
busy = true
scope.launch {
if (repository.deletePlaylist(playlist.id).isSuccess) {
playlists = playlists.filter { it.id != playlist.id }
containedBy = containedBy - playlist.id
// The removed row leaves focus dangling — park it on the rows.
kotlinx.coroutines.delay(150)
runCatching { rowsFocus.requestFocus() }
kotlinx.coroutines.delay(1000)
refreshHits()
}
busy = false
}
}
LaunchedEffect(loading) {
if (!loading && !failed && playlists.isNotEmpty()) {
kotlinx.coroutines.delay(150)
@ -585,35 +354,11 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
modifier = Modifier
.weight(1f)
.focusRequester(inputFocus)
// The field is the D-pad entry point to creating a playlist:
// UP from the first list row lands here (opens the IME) and
// DOWN returns to the list.
.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown) {
when (ke.nativeKeyEvent.keyCode) {
// DOWN returns to the list; RIGHT goes to the
// Create button (single-line TV field — cursor
// moves are worth less than being able to leave).
KeyEvent.KEYCODE_DPAD_DOWN -> {
runCatching { rowsFocus.requestFocus() }
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
runCatching { createBtnFocus.requestFocus() }
true
}
else -> false
}
} else false
}
.padding(start = 16.dp),
)
Button(
onClick = { create() },
modifier = Modifier
.padding(start = 10.dp, end = 16.dp)
.focusRequester(createBtnFocus)
.dpadCenterActivatable({ create() }),
modifier = Modifier.padding(start = 10.dp, end = 16.dp),
) { Text("Create") }
}
when {
@ -623,17 +368,11 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
else ->
LazyColumn(Modifier.fillMaxSize()) {
playlists.forEachIndexed { index, playlist ->
val checked = checked(playlist.id)
val checked = containedBy.containsKey(playlist.id)
item(key = "p" + playlist.id) {
SidebarRow(
onLeft = onDismiss,
onUp = if (index == 0) {
{ runCatching { inputFocus.requestFocus() } }
} else null,
atTop = index == 0,
atBottom = index == playlists.size - 1,
onClick = { toggle(playlist) },
onLongPress = { requestRemove(playlist) },
focusRequester = if (index == 0) rowsFocus else null,
) {
PlaylistCheckbox(checked)
@ -650,33 +389,6 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
}
}
}
// Long-press confirmation: the playlist's videos stay on the instance.
removeCandidate?.let { candidate ->
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(candidate) {
kotlinx.coroutines.delay(150)
runCatching { cancelFocus.requestFocus() }
}
androidx.compose.material3.AlertDialog(
onDismissRequest = { removeCandidate = null },
title = { Text("Remove playlist?") },
text = {
Text("\"" + candidate.displayName + "\" will be deleted. The videos in it stay on the instance.")
},
confirmButton = {
androidx.compose.material3.TextButton(onClick = {
removeCandidate = null
delete(candidate)
}) { Text("Remove") }
},
dismissButton = {
androidx.compose.material3.TextButton(
onClick = { removeCandidate = null },
modifier = Modifier.focusRequester(cancelFocus),
) { Text("Cancel") }
},
)
}
}
@Composable
@ -685,25 +397,8 @@ private fun PlaylistCheckbox(checked: Boolean) {
modifier = Modifier
.size(22.dp)
.clip(RoundedCornerShape(5.dp))
.background(Color(0x33FFFFFF))
.border(
1.dp,
if (checked) Accent else Color(0x66FFFFFF),
RoundedCornerShape(5.dp),
),
contentAlignment = Alignment.Center,
) {
// A red check inside the box marks membership (a plain red box read as
// a status color, not as "checked").
if (checked) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = Accent,
modifier = Modifier.size(16.dp),
)
}
}
.background(if (checked) Accent else Color(0x33FFFFFF)),
)
}
private fun formatDateShort(iso: String?): String {

View file

@ -50,6 +50,8 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage
import android.util.Log
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
@ -59,7 +61,6 @@ import net.jeena.jtubetv.data.api.Avatar
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
@ -124,9 +125,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>()
/** 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"
@ -143,15 +141,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window:
// repaint the in-memory rows and skip the network (issue #13) — rows are
// never torn down on re-entry; an account switch builds a new ViewModel.
if (!force && userRows.isNotEmpty() && fresh()) return
if (!force && userRows.isNotEmpty()) return
loading = true
error = null
viewModelScope.launch {
try {
// The user's own row first (fast: two calls).
// The user's own row renders FIRST (fast: one call), the rest streams in.
user = repo.me().getOrNull()
userChannels = user?.let { u ->
repo.accountChannels(u.username).getOrNull() ?: emptyList()
@ -159,18 +154,39 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
// Fast path (~3-4 requests): the local video listing already carries
// per-channel and per-account data + thumbnails, so every local row
// renders complete within seconds. The old flow walked 13 pages of
// the all-channels listing (1237 rows on this instance, most
// federated), re-sorting after every page (issue #13).
loadLocalsFromVideos(host)
loading = false
// enum the whole channel list (federation-follow listing incl. local)
val all = mutableListOf<VideoChannelDto>()
var start = 0L
var total: Long? = null
var retries = 0
while (total == null || start < total) {
val page = repo.channelList(start, 100).getOrNull()
if (page == null) {
retries++
if (retries > 2) break
delay(500)
continue
}
retries = 0
if (total == null) total = page.second
val known = all.map { it.name to it.host }.toSet()
all += page.first.filter { c -> (c.name to c.host) !in known }
start += 100
android.util.Log.w(
"JTube",
"channels page " + (start / 100) + ": total=" + total + " all=" + all.size +
" locals=" + all.count { it.host == host },
)
allChannels = all
rebuildRows(host)
}
ImagePrefetcher.channelArt(all + userChannels)
// Background: the channel listing only refines — banners, exact
// follower counts, owner accounts for the row headers, the
// zero-video locals, and the followed-instance rows.
refineFromListing(host)
// stats (videos + plays) for every local channel: throttled parallel-ish
val locals = all.filter { it.host == host }
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size +
" userChannels=" + userChannels.size)
channelStatsLoop(locals, host)
} catch (e: Exception) {
error = e.message ?: "Could not load channels"
} finally {
@ -179,107 +195,31 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
}
}
/** Re-entry skip window. */
private fun fresh(): Boolean =
lastLoadedAt > 0 &&
android.os.SystemClock.elapsedRealtime() - lastLoadedAt < 5 * 60_000L
/**
* Builds the local rows from `videos?isLocal=true` (sorted -publishedAt):
* groups per channel handle for stats (exact video count + play sum as long
* as the whole local library fits in a few pages) and per owner-account for
* the row headers. No per-channel requests at all.
* Fetch stats for [channels] in parallel batches of 6 (each stat call walks
* up to 4 video pages — strictly sequential with ~40 channels took minutes),
* re-sorting the rows whenever a batch lands. Persisted per batch so the
* next start starts warm.
*/
private suspend fun loadLocalsFromVideos(host: String?) {
val videos = mutableListOf<VideoDto>()
var start = 0L
var total: Long
do {
val page = repo.localVideos(start, 100).getOrNull() ?: break
videos += page.data
total = page.total
start += page.data.size
} while (start < total && videos.size < 2000)
val base = AppGraph.repository
for ((key, vids) in videos.groupBy { it.channel.name + "@" + it.channel.host }) {
val first = vids.first()
statsMap[key] = ChannelStats(
videos = vids.size.toLong(),
plays = vids.sumOf { it.views },
latestThumbUrl = first.thumbnailPath
?.takeIf { it.isNotBlank() }
?.let { base.absoluteUrl(it) },
)
if (allChannels.none { it.name == first.channel.name && it.host == first.channel.host }) {
allChannels += VideoChannelDto(
name = first.channel.name,
displayName = first.channel.displayName,
host = first.channel.host,
avatars = first.channel.avatars,
// video.account IS the channel's owner — enough for row grouping.
ownerAccount = first.account,
)
}
}
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)
}
/**
* Streams the full channel listing (thirteen 100-row pages on an instance
* following others — the locals are spread thin across them). Replaces the
* local stubs with full objects (banners + exact subscribers + owner), adds
* the zero-video locals that the video listing cannot see, and builds the
* followed-instance rows. Rebuilds only when a group actually changed.
*/
private fun refineFromListing(host: String?) {
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) }
viewModelScope.launch {
var start = 0L
var total: Long? = null
var retries = 0
var localsFingerprint = ""
var foreignCount = -1
while (total == null || start < total) {
val page = repo.channelList(start, 100).getOrNull()
if (page == null) {
retries++
if (retries > 2) break
delay(500)
continue
missing.chunked(6).forEach { batch ->
batch.map { channel ->
async {
val key = channel.name + "@" + channel.host
val cs = repo.channelStats(key).getOrNull()
if (cs != null) Pair(key, cs) else null
}
}.awaitAll().filterNotNull().forEach { (key, cs) ->
statsMap[key] = cs
}
retries = 0
if (total == null) total = page.second
val merged = allChannels.toMutableList()
for (c in page.first) {
val existing = merged.indexOfFirst { it.name == c.name && it.host == c.host }
if (existing >= 0) merged[existing] = c
else merged += c
}
allChannels = merged
start += 100
android.util.Log.w(
"JTube",
"listing page " + (start / 100) + ": total=" + total + " all=" + allChannels.size,
)
// Full locals: banners/subscribers may have changed → repaint.
val fp = allChannels.filter { it.host == host }
.joinToString("|") { it.name + ":" + it.followersCount }
if (fp != localsFingerprint) {
localsFingerprint = fp
rebuildRows(host)
}
val fc = allChannels.count { it.host != host }
if (fc != foreignCount) {
foreignCount = fc
rebuildRows(host) // rebuilds both; userRows stay key-stable
stats = statsMap.toMap()
rebuildRows(host)
snapshotContext?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
}
}
}
}
@ -369,7 +309,7 @@ fun ChannelsScreen(
onOpenDrawer: () -> Unit = {},
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) {
LaunchedEffect(epoch) { viewModel.load() }
LaunchedEffect(epoch) { viewModel.load(force = true) }
when {

View file

@ -257,7 +257,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
channelDetails = handle
channelName = displayName
},
onOpenVideo = { playingVideo = it },
)
}
}

View file

@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Replay10
@ -195,8 +194,6 @@ fun PlayerScreen(
// From the paused/controls overlay's channel icon: (handle, displayName),
// must also exit the player screen first.
onOpenChannel: (String, String) -> Unit = { _, _ -> },
// From the info sidebar's PeerTube video links: switches the player over.
onOpenVideo: (String) -> Unit = {},
viewModel: PlayerViewModel = viewModel(
key = "player$shortUuid",
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
@ -214,7 +211,7 @@ fun PlayerScreen(
when (val s = state) {
is PlayerUiState.Loading -> PlayerLoading()
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel)
}
}
@ -277,7 +274,6 @@ private fun PlayerSurface(
shortUuid: String,
onBack: () -> Unit,
onOpenChannel: (String, String) -> Unit,
onOpenVideo: (String) -> Unit,
) {
val context = LocalContext.current
val keyFocus = remember { FocusRequester() }
@ -296,14 +292,6 @@ private fun PlayerSurface(
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
// Right sidebar (issue #10): "comments" | "playlists"; null = closed.
var sidebar by remember { mutableStateOf<String?>(null) }
// The pill that opened the sidebar — on close it regains focus, not the
// first pill of the row.
var sidebarReturn by remember { mutableStateOf<FocusRequester?>(null) }
fun closeSidebar() {
sidebar = null
runCatching { (sidebarReturn ?: actionBarFocus).requestFocus() }
sidebarReturn = null
}
val player = remember {
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
@ -477,15 +465,12 @@ private fun PlayerSurface(
val code = ke.nativeKeyEvent.keyCode
when {
// BACK/Esc: close the sidebar first, else leave the player.
// Only on ACTION_DOWN: the trailing ACTION_UP of the same
// press must not see sidebar==null and exit the video.
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
if (sidebar != null) {
closeSidebar()
} else {
onBack()
}
if (sidebar != null) {
sidebar = null
runCatching { actionBarFocus.requestFocus() }
} else {
onBack()
}
true
}
@ -566,22 +551,21 @@ private fun PlayerSurface(
seekBarFocus = seekBarFocus,
actionBarFocus = actionBarFocus,
onOpenChannel = onOpenChannel,
openSidebar = { mode, opener ->
openSidebar = { mode ->
sidebar = mode
sidebarReturn = opener
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Right sidebar (comments / playlists / info); the video is shrunk to its left.
// Right sidebar (comments / playlists); the video is shrunk to its left.
sidebar?.let { mode ->
PlayerSidebar(
mode = mode,
detail = s.detail,
onDismiss = {
closeSidebar()
sidebar = null
runCatching { actionBarFocus.requestFocus() }
},
onOpenVideo = onOpenVideo,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(top = 48.dp),
@ -591,8 +575,6 @@ private fun PlayerSurface(
}
/** Seek ±[deltaMs] inside the video bounds; updates the overlay's position state. */
private val VideoAccent = Color(0xFFCE2B2B)
private fun seekOverlay(
player: ExoPlayer,
positionMs: androidx.compose.runtime.MutableLongState,
@ -623,18 +605,13 @@ private fun PausedControls(
onOpenChannel: (String, String) -> Unit = { _, _ -> },
// First action-row button focus (used by the surface when a sidebar closes).
actionBarFocus: FocusRequester? = null,
// "comments" | "playlists" — opens the right sidebar (issue #10); the
// opener pill's focuser travels along so it regains focus on close.
openSidebar: (String, FocusRequester) -> Unit = { _, _ -> },
// "comments" | "playlists" — opens the right sidebar (issue #10).
openSidebar: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val durationMs = detail.duration * 1000L
val scope = androidx.compose.runtime.rememberCoroutineScope()
val channelFocus = remember { FocusRequester() }
// Focusers of the sidebar-opening pills (their panels come back to them).
val commentsPillFocus = remember { FocusRequester() }
val playlistsPillFocus = remember { FocusRequester() }
val infoPillFocus = remember { FocusRequester() }
// While playing, the position text must keep counting.
LaunchedEffect(playing) {
while (playing) {
@ -749,7 +726,7 @@ private fun PausedControls(
modifier = Modifier.padding(top = 12.dp),
)
// Action row under the red line (issue #10): subscribe, like/dislike,
// info + comments/playlists open the right sidebar; times on the right.
// comments + playlists open the right sidebar; times on the right.
val repository = AppGraph.repository
val scope = androidx.compose.runtime.rememberCoroutineScope()
var subscribed by remember { mutableStateOf<Boolean?>(null) }
@ -766,15 +743,13 @@ private fun PausedControls(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.Groups,
desc = if (subscribed == true) "Subscribed" else "Subscribe",
filled = subscribed == true,
iconSize = androidx.compose.ui.unit.DpSize(33.dp, 33.dp),
flushLeft = true,
focusRequester = actionBarFocus,
) { done ->
scope.launch {
@ -785,18 +760,17 @@ private fun PausedControls(
repository.subscribe(channelHandle).isSuccess
}
if (ok) {
// Trust the user's action: the server's exist-check can
// lag minutes for federated channels (the follow must be
// accepted remotely), so a quick re-verify would stomp
// a perfectly good state back to unsubscribed. The
// state re-syncs from the server on next screen entry.
// Optimistic: the server's exist-check is eventually
// consistent right after a POST — trust our action, then
// re-verify a moment later.
subscribed = !wasSubscribed
kotlinx.coroutines.delay(1500)
repository.subscriptionExists(channelHandle).onSuccess { subscribed = it }
}
done()
}
}
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.ThumbUp,
desc = "Like",
count = likeCount,
@ -813,7 +787,6 @@ private fun PausedControls(
}
}
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.ThumbDown,
desc = "Dislike",
count = dislikeCount,
@ -830,31 +803,18 @@ private fun PausedControls(
}
}
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.Info,
desc = "Info",
focusRequester = infoPillFocus,
) { done ->
done()
openSidebar("info", infoPillFocus)
}
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.ChatBubbleOutline,
desc = "Comments",
focusRequester = commentsPillFocus,
) { done ->
done()
openSidebar("comments", commentsPillFocus)
openSidebar("comments")
}
PlayerActionButton(
upFocus = seekBarFocus,
icon = Icons.Filled.PlaylistAdd,
desc = "Playlists",
focusRequester = playlistsPillFocus,
) { done ->
done()
openSidebar("playlists", playlistsPillFocus)
openSidebar("playlists")
}
Spacer(modifier = Modifier.weight(1f))
Text(
@ -1022,12 +982,10 @@ private fun PlayerActionButton(
desc: String,
count: Long? = null,
filled: Boolean = false,
// Optically size the glyph: outlines/filled people glyphs differ in
// internal padding — subscribe's Groups icon reads smaller at equal dp.
iconSize: androidx.compose.ui.unit.DpSize = androidx.compose.ui.unit.DpSize(26.dp, 26.dp),
// First pill in the row: drop the left inner padding so the glyph aligns
// flush with the 24dp text margin.
flushLeft: Boolean = false,
focusRequester: FocusRequester? = null,
// UP target: the seek bar. Default focus search skips the bar and lands on the channel icon above it, so it is wired explicitly.
upFocus: FocusRequester? = null,
// [onClick] receives an onDone callback; only call it when the action's
// network roundtrip finished — events arriving before onDone are dropped.
onClick: (onDone: () -> Unit) -> Unit,
@ -1048,43 +1006,36 @@ private fun PlayerActionButton(
if (focusRequester != null) m = m.focusRequester(focusRequester)
m = m
.onFocusChanged { focused = it.isFocused }
.onPreviewKeyEvent { ke ->
// UP returns to the seek bar explicitly — default focus search skips
// it and lands on the channel icon further up (observed live).
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_UP
) {
runCatching { upFocus?.requestFocus() }
upFocus != null
} else false
}
.dpadCenterActivatable({ activate() })
.focusable()
// Selection: gray pill; nothing at all otherwise — the active state
// (subscribed / rated) is marked by the icon turning red instead.
.background(
if (focused) Color(0x55FFFFFF) else Color.Transparent,
when {
focused -> Color(0x55FFFFFF)
filled -> Color(0x33CE2B2B)
else -> Color(0x22FFFFFF)
},
RoundedCornerShape(20.dp),
)
.then(
if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp))
else Modifier
)
// Click only for real pointer input; the key activation above is guarded.
.pointerInput(Unit) { detectTapGestures { activate() } }
// Icon inset inside the pill — must come AFTER the background so the
// gray pill wraps the glyph on every side.
.padding(horizontal = 16.dp, vertical = 9.dp)
// [m] carries focusRequester/onFocusChanged/dpadCenterActivatable/focusable
// plus background, border and the icon inset — it must stay attached to
// the Row, otherwise the button is not focusable at all (D-pad can't reach
// it) and the visuals collapse.
Row(
modifier = m,
modifier = Modifier.padding(
start = if (flushLeft) 0.dp else 12.dp,
top = 8.dp,
end = 12.dp,
bottom = 8.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.material3.Icon(
imageVector = icon,
contentDescription = desc,
// Same red as the seek bar/playlist accent — marks active state.
tint = if (filled) VideoAccent else Color.White,
modifier = Modifier.size(iconSize),
tint = Color.White,
modifier = Modifier.size(26.dp),
)
if (count != null) {
Text(

View file

@ -1,11 +1,7 @@
package net.jeena.jtubetv.ui.screens.list
import android.view.KeyEvent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.key.onPreviewKeyEvent
@ -18,58 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
*/
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
onPreviewKeyEvent { ke ->
val native = ke.nativeKeyEvent
val code = native.keyCode
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
code == android.view.KeyEvent.KEYCODE_ENTER ||
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == android.view.KeyEvent.KEYCODE_SPACE
// Fires exactly once per press, on ACTION_DOWN: the UP is consumed too
// (otherwise it leaks into an underlying clickable, which fires again —
// observed live: the Create button created a playlist twice), and key
// repeats are swallowed so holding OK cannot machine-gun the action.
when (native.action) {
android.view.KeyEvent.ACTION_DOWN -> {
if (activates) {
if (native.repeatCount == 0) onClick()
true
} else false
}
android.view.KeyEvent.ACTION_UP -> activates
else -> false
}
}
}
/**
* Two-stage TV activation for hold-sensitive rows: a short press runs
* [onClick]; holding OK for ~600 ms (checked at key release, not during —
* so the dialog the long-press opens never receives the trailing repeats
* and release of the hold that triggered it) runs [onLongPress] instead
* and suppresses the click. Everything is consumed; the platform click
* that some devices fire on top of CENTER never double-activates.
*/
fun Modifier.dpadCenterTwoStage(onClick: () -> Unit, onLongPress: () -> Unit): Modifier = composed {
var downAt by remember { mutableStateOf(0L) }
onPreviewKeyEvent { ke ->
val native = ke.nativeKeyEvent
val code = native.keyCode
val activates = code == KeyEvent.KEYCODE_DPAD_CENTER ||
code == KeyEvent.KEYCODE_ENTER ||
code == KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == KeyEvent.KEYCODE_SPACE
if (!activates) return@onPreviewKeyEvent false
when (native.action) {
KeyEvent.ACTION_DOWN -> {
if (native.repeatCount == 0) downAt = android.os.SystemClock.uptimeMillis()
true
}
KeyEvent.ACTION_UP -> {
val held = downAt > 0 && android.os.SystemClock.uptimeMillis() - downAt >= 600
if (held) onLongPress() else onClick()
true
}
else -> false
}
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
val code = ke.nativeKeyEvent.keyCode
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
code == android.view.KeyEvent.KEYCODE_ENTER ||
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == android.view.KeyEvent.KEYCODE_SPACE
if (activates) {
onClick()
true // consumed in preview -> the platform/card path never double-fires
} else false
} else false
}
}