Compare commits
No commits in common. "29dee24849198175db8b76adfcbfe7585c97e367" and "c70d98ac6453315ec59221a8a970ba11439e45d5" have entirely different histories.
29dee24849
...
c70d98ac64
9 changed files with 166 additions and 615 deletions
|
|
@ -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.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -257,7 +257,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
channelDetails = handle
|
||||
channelName = displayName
|
||||
},
|
||||
onOpenVideo = { playingVideo = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue