Compare commits
2 commits
c70d98ac64
...
29dee24849
| Author | SHA1 | Date | |
|---|---|---|---|
| 29dee24849 | |||
| f5b37dd21d |
9 changed files with 619 additions and 170 deletions
|
|
@ -77,4 +77,9 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev
|
||||||
- Comments viewing (read-only) — needs moderation-safe UI thinking.
|
- Comments viewing (read-only) — needs moderation-safe UI thinking.
|
||||||
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
|
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
|
||||||
- Out of scope by user request (do not implement without asking): uploading, editing,
|
- Out of scope by user request (do not implement without asking): uploading, editing,
|
||||||
subscribing/unsubscribing, deleting, creating channels/users/playlists.
|
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.
|
||||||
|
|
@ -125,32 +125,6 @@ class PeerTubeRepository(
|
||||||
api.serverConfig().instance.name ?: rawHost
|
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 =
|
private fun unauthenticatedApiFor(base: String): PeerTubeApi =
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(apiUrlOf(base) + "/")
|
.baseUrl(apiUrlOf(base) + "/")
|
||||||
|
|
@ -297,6 +271,9 @@ class PeerTubeRepository(
|
||||||
suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result<Unit> =
|
suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result<Unit> =
|
||||||
runCatching { network.api.removeFromPlaylist(playlistId, elementId) }
|
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 {
|
suspend fun createPlaylist(displayName: String): Result<VideoPlaylistSummaryDto?> = runCatching {
|
||||||
val created = network.api.createPlaylist(CreatePlaylistBody(displayName))
|
val created = network.api.createPlaylist(CreatePlaylistBody(displayName))
|
||||||
.videoPlaylist ?: throw IllegalStateException("No playlist in response")
|
.videoPlaylist ?: throw IllegalStateException("No playlist in response")
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ data class VideoDto(
|
||||||
val duration: Int = 0,
|
val duration: Int = 0,
|
||||||
@SerialName("publishedAt") val publishedAt: String? = null,
|
@SerialName("publishedAt") val publishedAt: String? = null,
|
||||||
val thumbnailPath: 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 privacy: Labelled? = null,
|
||||||
val nsfw: Boolean = false,
|
val nsfw: Boolean = false,
|
||||||
val views: Long = 0,
|
val views: Long = 0,
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,9 @@ interface PeerTubeApi {
|
||||||
@Body body: AddToPlaylistBody,
|
@Body body: AddToPlaylistBody,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@DELETE("video-playlists/{playlistId}")
|
||||||
|
suspend fun deletePlaylist(@Path("playlistId") playlistId: Long): Response<Unit>
|
||||||
|
|
||||||
@DELETE("video-playlists/{playlistId}/videos/{elementId}")
|
@DELETE("video-playlists/{playlistId}/videos/{elementId}")
|
||||||
suspend fun removeFromPlaylist(
|
suspend fun removeFromPlaylist(
|
||||||
@Path("playlistId") playlistId: Long,
|
@Path("playlistId") playlistId: Long,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
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.graphics.Color
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
|
|
@ -43,10 +50,15 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.Button
|
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 coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.jeena.jtubetv.AppGraph
|
import net.jeena.jtubetv.AppGraph
|
||||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
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.VideoPlaylistSummaryDto
|
||||||
import net.jeena.jtubetv.data.api.CommentThreadDto
|
import net.jeena.jtubetv.data.api.CommentThreadDto
|
||||||
import net.jeena.jtubetv.data.api.VideoDetailDto
|
import net.jeena.jtubetv.data.api.VideoDetailDto
|
||||||
|
|
@ -66,9 +78,13 @@ private val Accent = Color(0xFFCE2B2B)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerSidebar(
|
fun PlayerSidebar(
|
||||||
mode: String, // "comments" | "playlists"
|
mode: String, // "comments" | "playlists" | "info"
|
||||||
detail: VideoDetailDto,
|
detail: VideoDetailDto,
|
||||||
onDismiss: () -> Unit,
|
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,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -78,15 +94,148 @@ fun PlayerSidebar(
|
||||||
.background(SidebarBg),
|
.background(SidebarBg),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (mode == "playlists") "Playlists" else "Comments",
|
text = when (mode) {
|
||||||
|
"playlists" -> "Playlists"
|
||||||
|
"info" -> "Info"
|
||||||
|
else -> "Comments"
|
||||||
|
},
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier.padding(20.dp),
|
||||||
)
|
)
|
||||||
if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss)
|
when (mode) {
|
||||||
|
"playlists" -> PlaylistsColumn(detail, onDismiss)
|
||||||
|
"info" -> InfoColumn(detail, onDismiss, onOpenVideo, onOpenChannel)
|
||||||
|
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). */
|
/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
||||||
|
|
@ -102,11 +251,17 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(20.dp)
|
.padding(20.dp)
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
if (ke.type == KeyEventType.KeyDown &&
|
if (ke.type == KeyEventType.KeyDown) {
|
||||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
when (ke.nativeKeyEvent.keyCode) {
|
||||||
) {
|
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
onDismiss()
|
onDismiss(); true
|
||||||
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
|
||||||
|
}
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
.focusRequester(fr)
|
.focusRequester(fr)
|
||||||
|
|
@ -114,13 +269,23 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */
|
/** 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. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SidebarRow(
|
private fun SidebarRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onLeft: (() -> Unit)? = null,
|
onLeft: (() -> Unit)? = null,
|
||||||
|
onUp: (() -> Unit)? = null,
|
||||||
|
atTop: Boolean = false,
|
||||||
|
atBottom: Boolean = false,
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: (() -> Unit)? = 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,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
|
@ -129,19 +294,31 @@ private fun SidebarRow(
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
|
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
|
||||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
||||||
if (onLeft != null) {
|
m = m.onPreviewKeyEvent { ke ->
|
||||||
m = m.onPreviewKeyEvent { ke ->
|
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
if (ke.type == KeyEventType.KeyDown &&
|
when (ke.nativeKeyEvent.keyCode) {
|
||||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
) {
|
if (onLeft != null) onLeft()
|
||||||
onLeft()
|
onLeft != null
|
||||||
true
|
}
|
||||||
} else false
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m = m
|
m = if (onLongPress != null) {
|
||||||
.dpadCenterActivatable(onClick ?: {})
|
m.dpadCenterTwoStage(onClick ?: {}, onLongPress)
|
||||||
.focusable()
|
} else {
|
||||||
|
m.dpadCenterActivatable(onClick ?: {})
|
||||||
|
}
|
||||||
|
m = m.focusable()
|
||||||
if (onClick != null) m = m.clickable(onClick = onClick)
|
if (onClick != null) m = m.clickable(onClick = onClick)
|
||||||
Row(
|
Row(
|
||||||
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
|
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||||
|
|
@ -212,6 +389,8 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
CommentRow(
|
CommentRow(
|
||||||
comment = comment,
|
comment = comment,
|
||||||
onLeft = onDismiss,
|
onLeft = onDismiss,
|
||||||
|
atTop = index == 0,
|
||||||
|
atBottom = index == items.size - 1,
|
||||||
focusRequester = if (index == 0) firstRow else null,
|
focusRequester = if (index == 0) firstRow else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -235,12 +414,16 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
private fun CommentRow(
|
private fun CommentRow(
|
||||||
comment: CommentThreadDto,
|
comment: CommentThreadDto,
|
||||||
onLeft: () -> Unit,
|
onLeft: () -> Unit,
|
||||||
|
atTop: Boolean = false,
|
||||||
|
atBottom: Boolean = false,
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
) {
|
) {
|
||||||
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
|
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
|
||||||
SidebarRow(
|
SidebarRow(
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
onLeft = onLeft,
|
onLeft = onLeft,
|
||||||
|
atTop = atTop,
|
||||||
|
atBottom = atBottom,
|
||||||
focusRequester = focusRequester,
|
focusRequester = focusRequester,
|
||||||
) {
|
) {
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
|
|
@ -283,19 +466,34 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
|
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
|
||||||
var containedBy by remember { mutableStateOf<Map<Long, Long>>(emptyMap()) }
|
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 loading by remember { mutableStateOf(true) }
|
||||||
var failed by remember { mutableStateOf(false) }
|
var failed by remember { mutableStateOf(false) }
|
||||||
var newName by remember { mutableStateOf("") }
|
var newName by remember { mutableStateOf("") }
|
||||||
val inputFocus = remember { FocusRequester() }
|
val inputFocus = remember { FocusRequester() }
|
||||||
val rowsFocus = remember { FocusRequester() }
|
val rowsFocus = remember { FocusRequester() }
|
||||||
|
val createBtnFocus = remember { FocusRequester() }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
suspend fun refreshHits() {
|
suspend fun refreshHits() {
|
||||||
repository.playlistsContaining(detail.id).fold(
|
repository.playlistsContaining(detail.id).fold(
|
||||||
onSuccess = { containedBy = it },
|
onSuccess = {
|
||||||
|
containedBy = it
|
||||||
|
addedOptimistically = emptySet()
|
||||||
|
removedOptimistically = emptySet()
|
||||||
|
},
|
||||||
onFailure = { },
|
onFailure = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun checked(playlistId: Long): Boolean =
|
||||||
|
(containedBy.containsKey(playlistId) && playlistId !in removedOptimistically) ||
|
||||||
|
playlistId in addedOptimistically
|
||||||
|
|
||||||
LaunchedEffect(detail.id) {
|
LaunchedEffect(detail.id) {
|
||||||
repository.myPlaylists().fold(
|
repository.myPlaylists().fold(
|
||||||
onSuccess = { playlists = it },
|
onSuccess = { playlists = it },
|
||||||
|
|
@ -306,16 +504,28 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggle(playlist: VideoPlaylistSummaryDto) {
|
fun toggle(playlist: VideoPlaylistSummaryDto) {
|
||||||
val elementId = containedBy[playlist.id]
|
// 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
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (elementId != null) {
|
if (inside) {
|
||||||
repository.removeFromPlaylist(playlist.id, elementId)
|
val elementId = containedBy[playlist.id] ?: return@launch
|
||||||
|
if (repository.removeFromPlaylist(playlist.id, elementId).isSuccess) {
|
||||||
|
removedOptimistically = removedOptimistically + playlist.id
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
repository.addToPlaylist(playlist.id, detail.id)
|
addedOptimistically = addedOptimistically + playlist.id
|
||||||
|
if (repository.addToPlaylist(playlist.id, detail.id).isFailure) {
|
||||||
|
addedOptimistically = addedOptimistically - playlist.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Membership checks settle server-side; re-check after a moment.
|
// Membership checks settle server-side; re-check after a moment.
|
||||||
kotlinx.coroutines.delay(1000)
|
kotlinx.coroutines.delay(1000)
|
||||||
refreshHits()
|
refreshHits()
|
||||||
|
busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,6 +542,27 @@ 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) {
|
LaunchedEffect(loading) {
|
||||||
if (!loading && !failed && playlists.isNotEmpty()) {
|
if (!loading && !failed && playlists.isNotEmpty()) {
|
||||||
kotlinx.coroutines.delay(150)
|
kotlinx.coroutines.delay(150)
|
||||||
|
|
@ -354,11 +585,35 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.focusRequester(inputFocus)
|
.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),
|
.padding(start = 16.dp),
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { create() },
|
onClick = { create() },
|
||||||
modifier = Modifier.padding(start = 10.dp, end = 16.dp),
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp, end = 16.dp)
|
||||||
|
.focusRequester(createBtnFocus)
|
||||||
|
.dpadCenterActivatable({ create() }),
|
||||||
) { Text("Create") }
|
) { Text("Create") }
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
|
|
@ -368,11 +623,17 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||||
else ->
|
else ->
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
playlists.forEachIndexed { index, playlist ->
|
playlists.forEachIndexed { index, playlist ->
|
||||||
val checked = containedBy.containsKey(playlist.id)
|
val checked = checked(playlist.id)
|
||||||
item(key = "p" + playlist.id) {
|
item(key = "p" + playlist.id) {
|
||||||
SidebarRow(
|
SidebarRow(
|
||||||
onLeft = onDismiss,
|
onLeft = onDismiss,
|
||||||
|
onUp = if (index == 0) {
|
||||||
|
{ runCatching { inputFocus.requestFocus() } }
|
||||||
|
} else null,
|
||||||
|
atTop = index == 0,
|
||||||
|
atBottom = index == playlists.size - 1,
|
||||||
onClick = { toggle(playlist) },
|
onClick = { toggle(playlist) },
|
||||||
|
onLongPress = { requestRemove(playlist) },
|
||||||
focusRequester = if (index == 0) rowsFocus else null,
|
focusRequester = if (index == 0) rowsFocus else null,
|
||||||
) {
|
) {
|
||||||
PlaylistCheckbox(checked)
|
PlaylistCheckbox(checked)
|
||||||
|
|
@ -389,6 +650,33 @@ 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
|
@Composable
|
||||||
|
|
@ -397,8 +685,25 @@ private fun PlaylistCheckbox(checked: Boolean) {
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(22.dp)
|
.size(22.dp)
|
||||||
.clip(RoundedCornerShape(5.dp))
|
.clip(RoundedCornerShape(5.dp))
|
||||||
.background(if (checked) Accent else Color(0x33FFFFFF)),
|
.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDateShort(iso: String?): String {
|
private fun formatDateShort(iso: String?): String {
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@ import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.jeena.jtubetv.AppGraph
|
import net.jeena.jtubetv.AppGraph
|
||||||
|
|
@ -61,6 +59,7 @@ import net.jeena.jtubetv.data.api.Avatar
|
||||||
import net.jeena.jtubetv.data.api.ChannelStats
|
import net.jeena.jtubetv.data.api.ChannelStats
|
||||||
import net.jeena.jtubetv.data.api.UserDto
|
import net.jeena.jtubetv.data.api.UserDto
|
||||||
import net.jeena.jtubetv.data.api.VideoChannelDto
|
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.Snapshots
|
||||||
import net.jeena.jtubetv.ui.list.ImagePrefetcher
|
import net.jeena.jtubetv.ui.list.ImagePrefetcher
|
||||||
import net.jeena.jtubetv.ui.screens.list.ContainerShape
|
import net.jeena.jtubetv.ui.screens.list.ContainerShape
|
||||||
|
|
@ -125,6 +124,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
private val repo: PeerTubeRepository = AppGraph.repository
|
private val repo: PeerTubeRepository = AppGraph.repository
|
||||||
private val statsMap = HashMap<String, ChannelStats>()
|
private val statsMap = HashMap<String, ChannelStats>()
|
||||||
|
|
||||||
|
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
|
||||||
|
private var lastLoadedAt = 0L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Snapshot restore (issue #9): paint the last session's channel rows
|
// Snapshot restore (issue #9): paint the last session's channel rows
|
||||||
// instantly, including the per-channel stats (the "minutes of loading"
|
// instantly, including the per-channel stats (the "minutes of loading"
|
||||||
|
|
@ -141,12 +143,15 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
|
|
||||||
|
|
||||||
fun load(force: Boolean = false) {
|
fun load(force: Boolean = false) {
|
||||||
if (!force && userRows.isNotEmpty()) return
|
// 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
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// The user's own row renders FIRST (fast: one call), the rest streams in.
|
// The user's own row first (fast: two calls).
|
||||||
user = repo.me().getOrNull()
|
user = repo.me().getOrNull()
|
||||||
userChannels = user?.let { u ->
|
userChannels = user?.let { u ->
|
||||||
repo.accountChannels(u.username).getOrNull() ?: emptyList()
|
repo.accountChannels(u.username).getOrNull() ?: emptyList()
|
||||||
|
|
@ -154,39 +159,18 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
|
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
|
||||||
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
|
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
|
||||||
|
|
||||||
// enum the whole channel list (federation-follow listing incl. local)
|
// Fast path (~3-4 requests): the local video listing already carries
|
||||||
val all = mutableListOf<VideoChannelDto>()
|
// per-channel and per-account data + thumbnails, so every local row
|
||||||
var start = 0L
|
// renders complete within seconds. The old flow walked 13 pages of
|
||||||
var total: Long? = null
|
// the all-channels listing (1237 rows on this instance, most
|
||||||
var retries = 0
|
// federated), re-sorting after every page (issue #13).
|
||||||
while (total == null || start < total) {
|
loadLocalsFromVideos(host)
|
||||||
val page = repo.channelList(start, 100).getOrNull()
|
loading = false
|
||||||
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)
|
|
||||||
|
|
||||||
// stats (videos + plays) for every local channel: throttled parallel-ish
|
// Background: the channel listing only refines — banners, exact
|
||||||
val locals = all.filter { it.host == host }
|
// follower counts, owner accounts for the row headers, the
|
||||||
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size +
|
// zero-video locals, and the followed-instance rows.
|
||||||
" userChannels=" + userChannels.size)
|
refineFromListing(host)
|
||||||
channelStatsLoop(locals, host)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Could not load channels"
|
error = e.message ?: "Could not load channels"
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -195,31 +179,107 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch stats for [channels] in parallel batches of 6 (each stat call walks
|
* Builds the local rows from `videos?isLocal=true` (sorted -publishedAt):
|
||||||
* up to 4 video pages — strictly sequential with ~40 channels took minutes),
|
* groups per channel handle for stats (exact video count + play sum as long
|
||||||
* re-sorting the rows whenever a batch lands. Persisted per batch so the
|
* as the whole local library fits in a few pages) and per owner-account for
|
||||||
* next start starts warm.
|
* the row headers. No per-channel requests at all.
|
||||||
*/
|
*/
|
||||||
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
|
private suspend fun loadLocalsFromVideos(host: String?) {
|
||||||
val missing = channels.filter { !statsMap.containsKey(it.name + "@" + it.host) }
|
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?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
missing.chunked(6).forEach { batch ->
|
var start = 0L
|
||||||
batch.map { channel ->
|
var total: Long? = null
|
||||||
async {
|
var retries = 0
|
||||||
val key = channel.name + "@" + channel.host
|
var localsFingerprint = ""
|
||||||
val cs = repo.channelStats(key).getOrNull()
|
var foreignCount = -1
|
||||||
if (cs != null) Pair(key, cs) else null
|
while (total == null || start < total) {
|
||||||
}
|
val page = repo.channelList(start, 100).getOrNull()
|
||||||
}.awaitAll().filterNotNull().forEach { (key, cs) ->
|
if (page == null) {
|
||||||
statsMap[key] = cs
|
retries++
|
||||||
|
if (retries > 2) break
|
||||||
|
delay(500)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
stats = statsMap.toMap()
|
retries = 0
|
||||||
rebuildRows(host)
|
if (total == null) total = page.second
|
||||||
snapshotContext?.let { ctx ->
|
val merged = allChannels.toMutableList()
|
||||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
for (c in page.first) {
|
||||||
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +369,7 @@ fun ChannelsScreen(
|
||||||
onOpenDrawer: () -> Unit = {},
|
onOpenDrawer: () -> Unit = {},
|
||||||
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(epoch) { viewModel.load(force = true) }
|
LaunchedEffect(epoch) { viewModel.load() }
|
||||||
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
channelDetails = handle
|
channelDetails = handle
|
||||||
channelName = displayName
|
channelName = displayName
|
||||||
},
|
},
|
||||||
|
onOpenVideo = { playingVideo = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.NotificationsNone
|
import androidx.compose.material.icons.filled.NotificationsNone
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.Groups
|
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.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||||
import androidx.compose.material.icons.filled.Replay10
|
import androidx.compose.material.icons.filled.Replay10
|
||||||
|
|
@ -194,6 +195,8 @@ fun PlayerScreen(
|
||||||
// From the paused/controls overlay's channel icon: (handle, displayName),
|
// From the paused/controls overlay's channel icon: (handle, displayName),
|
||||||
// must also exit the player screen first.
|
// must also exit the player screen first.
|
||||||
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
||||||
|
// From the info sidebar's PeerTube video links: switches the player over.
|
||||||
|
onOpenVideo: (String) -> Unit = {},
|
||||||
viewModel: PlayerViewModel = viewModel(
|
viewModel: PlayerViewModel = viewModel(
|
||||||
key = "player$shortUuid",
|
key = "player$shortUuid",
|
||||||
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
|
||||||
|
|
@ -211,7 +214,7 @@ fun PlayerScreen(
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is PlayerUiState.Loading -> PlayerLoading()
|
is PlayerUiState.Loading -> PlayerLoading()
|
||||||
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
||||||
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel)
|
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,6 +277,7 @@ private fun PlayerSurface(
|
||||||
shortUuid: String,
|
shortUuid: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenChannel: (String, String) -> Unit,
|
onOpenChannel: (String, String) -> Unit,
|
||||||
|
onOpenVideo: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val keyFocus = remember { FocusRequester() }
|
val keyFocus = remember { FocusRequester() }
|
||||||
|
|
@ -292,6 +296,14 @@ private fun PlayerSurface(
|
||||||
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
|
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
|
||||||
// Right sidebar (issue #10): "comments" | "playlists"; null = closed.
|
// Right sidebar (issue #10): "comments" | "playlists"; null = closed.
|
||||||
var sidebar by remember { mutableStateOf<String?>(null) }
|
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 player = remember {
|
||||||
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
|
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
|
||||||
|
|
@ -465,12 +477,15 @@ private fun PlayerSurface(
|
||||||
val code = ke.nativeKeyEvent.keyCode
|
val code = ke.nativeKeyEvent.keyCode
|
||||||
when {
|
when {
|
||||||
// BACK/Esc: close the sidebar first, else leave the player.
|
// 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 -> {
|
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
|
||||||
if (sidebar != null) {
|
if (ke.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
|
||||||
sidebar = null
|
if (sidebar != null) {
|
||||||
runCatching { actionBarFocus.requestFocus() }
|
closeSidebar()
|
||||||
} else {
|
} else {
|
||||||
onBack()
|
onBack()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -551,21 +566,22 @@ private fun PlayerSurface(
|
||||||
seekBarFocus = seekBarFocus,
|
seekBarFocus = seekBarFocus,
|
||||||
actionBarFocus = actionBarFocus,
|
actionBarFocus = actionBarFocus,
|
||||||
onOpenChannel = onOpenChannel,
|
onOpenChannel = onOpenChannel,
|
||||||
openSidebar = { mode ->
|
openSidebar = { mode, opener ->
|
||||||
sidebar = mode
|
sidebar = mode
|
||||||
|
sidebarReturn = opener
|
||||||
},
|
},
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Right sidebar (comments / playlists); the video is shrunk to its left.
|
// Right sidebar (comments / playlists / info); the video is shrunk to its left.
|
||||||
sidebar?.let { mode ->
|
sidebar?.let { mode ->
|
||||||
PlayerSidebar(
|
PlayerSidebar(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
detail = s.detail,
|
detail = s.detail,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
sidebar = null
|
closeSidebar()
|
||||||
runCatching { actionBarFocus.requestFocus() }
|
|
||||||
},
|
},
|
||||||
|
onOpenVideo = onOpenVideo,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.padding(top = 48.dp),
|
.padding(top = 48.dp),
|
||||||
|
|
@ -575,6 +591,8 @@ private fun PlayerSurface(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seek ±[deltaMs] inside the video bounds; updates the overlay's position state. */
|
/** Seek ±[deltaMs] inside the video bounds; updates the overlay's position state. */
|
||||||
|
private val VideoAccent = Color(0xFFCE2B2B)
|
||||||
|
|
||||||
private fun seekOverlay(
|
private fun seekOverlay(
|
||||||
player: ExoPlayer,
|
player: ExoPlayer,
|
||||||
positionMs: androidx.compose.runtime.MutableLongState,
|
positionMs: androidx.compose.runtime.MutableLongState,
|
||||||
|
|
@ -605,13 +623,18 @@ private fun PausedControls(
|
||||||
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
onOpenChannel: (String, String) -> Unit = { _, _ -> },
|
||||||
// First action-row button focus (used by the surface when a sidebar closes).
|
// First action-row button focus (used by the surface when a sidebar closes).
|
||||||
actionBarFocus: FocusRequester? = null,
|
actionBarFocus: FocusRequester? = null,
|
||||||
// "comments" | "playlists" — opens the right sidebar (issue #10).
|
// "comments" | "playlists" — opens the right sidebar (issue #10); the
|
||||||
openSidebar: (String) -> Unit = {},
|
// opener pill's focuser travels along so it regains focus on close.
|
||||||
|
openSidebar: (String, FocusRequester) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val durationMs = detail.duration * 1000L
|
val durationMs = detail.duration * 1000L
|
||||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
val channelFocus = remember { FocusRequester() }
|
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.
|
// While playing, the position text must keep counting.
|
||||||
LaunchedEffect(playing) {
|
LaunchedEffect(playing) {
|
||||||
while (playing) {
|
while (playing) {
|
||||||
|
|
@ -726,7 +749,7 @@ private fun PausedControls(
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
)
|
)
|
||||||
// Action row under the red line (issue #10): subscribe, like/dislike,
|
// Action row under the red line (issue #10): subscribe, like/dislike,
|
||||||
// comments + playlists open the right sidebar; times on the right.
|
// info + comments/playlists open the right sidebar; times on the right.
|
||||||
val repository = AppGraph.repository
|
val repository = AppGraph.repository
|
||||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
var subscribed by remember { mutableStateOf<Boolean?>(null) }
|
var subscribed by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
|
@ -743,13 +766,15 @@ private fun PausedControls(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 6.dp),
|
.padding(top = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
PlayerActionButton(
|
PlayerActionButton(
|
||||||
|
upFocus = seekBarFocus,
|
||||||
icon = Icons.Filled.Groups,
|
icon = Icons.Filled.Groups,
|
||||||
desc = if (subscribed == true) "Subscribed" else "Subscribe",
|
desc = if (subscribed == true) "Subscribed" else "Subscribe",
|
||||||
filled = subscribed == true,
|
filled = subscribed == true,
|
||||||
flushLeft = true,
|
iconSize = androidx.compose.ui.unit.DpSize(33.dp, 33.dp),
|
||||||
focusRequester = actionBarFocus,
|
focusRequester = actionBarFocus,
|
||||||
) { done ->
|
) { done ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -760,17 +785,18 @@ private fun PausedControls(
|
||||||
repository.subscribe(channelHandle).isSuccess
|
repository.subscribe(channelHandle).isSuccess
|
||||||
}
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// Optimistic: the server's exist-check is eventually
|
// Trust the user's action: the server's exist-check can
|
||||||
// consistent right after a POST — trust our action, then
|
// lag minutes for federated channels (the follow must be
|
||||||
// re-verify a moment later.
|
// 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.
|
||||||
subscribed = !wasSubscribed
|
subscribed = !wasSubscribed
|
||||||
kotlinx.coroutines.delay(1500)
|
|
||||||
repository.subscriptionExists(channelHandle).onSuccess { subscribed = it }
|
|
||||||
}
|
}
|
||||||
done()
|
done()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerActionButton(
|
PlayerActionButton(
|
||||||
|
upFocus = seekBarFocus,
|
||||||
icon = Icons.Filled.ThumbUp,
|
icon = Icons.Filled.ThumbUp,
|
||||||
desc = "Like",
|
desc = "Like",
|
||||||
count = likeCount,
|
count = likeCount,
|
||||||
|
|
@ -787,6 +813,7 @@ private fun PausedControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerActionButton(
|
PlayerActionButton(
|
||||||
|
upFocus = seekBarFocus,
|
||||||
icon = Icons.Filled.ThumbDown,
|
icon = Icons.Filled.ThumbDown,
|
||||||
desc = "Dislike",
|
desc = "Dislike",
|
||||||
count = dislikeCount,
|
count = dislikeCount,
|
||||||
|
|
@ -803,18 +830,31 @@ private fun PausedControls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerActionButton(
|
PlayerActionButton(
|
||||||
icon = Icons.Filled.ChatBubbleOutline,
|
upFocus = seekBarFocus,
|
||||||
desc = "Comments",
|
icon = Icons.Filled.Info,
|
||||||
|
desc = "Info",
|
||||||
|
focusRequester = infoPillFocus,
|
||||||
) { done ->
|
) { done ->
|
||||||
done()
|
done()
|
||||||
openSidebar("comments")
|
openSidebar("info", infoPillFocus)
|
||||||
}
|
}
|
||||||
PlayerActionButton(
|
PlayerActionButton(
|
||||||
icon = Icons.Filled.PlaylistAdd,
|
upFocus = seekBarFocus,
|
||||||
desc = "Playlists",
|
icon = Icons.Filled.ChatBubbleOutline,
|
||||||
|
desc = "Comments",
|
||||||
|
focusRequester = commentsPillFocus,
|
||||||
) { done ->
|
) { done ->
|
||||||
done()
|
done()
|
||||||
openSidebar("playlists")
|
openSidebar("comments", commentsPillFocus)
|
||||||
|
}
|
||||||
|
PlayerActionButton(
|
||||||
|
upFocus = seekBarFocus,
|
||||||
|
icon = Icons.Filled.PlaylistAdd,
|
||||||
|
desc = "Playlists",
|
||||||
|
focusRequester = playlistsPillFocus,
|
||||||
|
) { done ->
|
||||||
|
done()
|
||||||
|
openSidebar("playlists", playlistsPillFocus)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -982,10 +1022,12 @@ private fun PlayerActionButton(
|
||||||
desc: String,
|
desc: String,
|
||||||
count: Long? = null,
|
count: Long? = null,
|
||||||
filled: Boolean = false,
|
filled: Boolean = false,
|
||||||
// First pill in the row: drop the left inner padding so the glyph aligns
|
// Optically size the glyph: outlines/filled people glyphs differ in
|
||||||
// flush with the 24dp text margin.
|
// internal padding — subscribe's Groups icon reads smaller at equal dp.
|
||||||
flushLeft: Boolean = false,
|
iconSize: androidx.compose.ui.unit.DpSize = androidx.compose.ui.unit.DpSize(26.dp, 26.dp),
|
||||||
focusRequester: FocusRequester? = null,
|
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
|
// [onClick] receives an onDone callback; only call it when the action's
|
||||||
// network roundtrip finished — events arriving before onDone are dropped.
|
// network roundtrip finished — events arriving before onDone are dropped.
|
||||||
onClick: (onDone: () -> Unit) -> Unit,
|
onClick: (onDone: () -> Unit) -> Unit,
|
||||||
|
|
@ -1006,36 +1048,43 @@ private fun PlayerActionButton(
|
||||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
||||||
m = m
|
m = m
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.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() })
|
.dpadCenterActivatable({ activate() })
|
||||||
.focusable()
|
.focusable()
|
||||||
|
// Selection: gray pill; nothing at all otherwise — the active state
|
||||||
|
// (subscribed / rated) is marked by the icon turning red instead.
|
||||||
.background(
|
.background(
|
||||||
when {
|
if (focused) Color(0x55FFFFFF) else Color.Transparent,
|
||||||
focused -> Color(0x55FFFFFF)
|
|
||||||
filled -> Color(0x33CE2B2B)
|
|
||||||
else -> Color(0x22FFFFFF)
|
|
||||||
},
|
|
||||||
RoundedCornerShape(20.dp),
|
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.
|
// Click only for real pointer input; the key activation above is guarded.
|
||||||
.pointerInput(Unit) { detectTapGestures { activate() } }
|
.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(
|
Row(
|
||||||
modifier = Modifier.padding(
|
modifier = m,
|
||||||
start = if (flushLeft) 0.dp else 12.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
end = 12.dp,
|
|
||||||
bottom = 8.dp,
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
androidx.compose.material3.Icon(
|
androidx.compose.material3.Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = desc,
|
contentDescription = desc,
|
||||||
tint = Color.White,
|
// Same red as the seek bar/playlist accent — marks active state.
|
||||||
modifier = Modifier.size(26.dp),
|
tint = if (filled) VideoAccent else Color.White,
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
)
|
)
|
||||||
if (count != null) {
|
if (count != null) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package net.jeena.jtubetv.ui.screens.list
|
package net.jeena.jtubetv.ui.screens.list
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.composed
|
import androidx.compose.ui.composed
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
|
@ -14,16 +18,58 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
*/
|
*/
|
||||||
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
|
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
|
||||||
onPreviewKeyEvent { ke ->
|
onPreviewKeyEvent { ke ->
|
||||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
|
val native = ke.nativeKeyEvent
|
||||||
val code = ke.nativeKeyEvent.keyCode
|
val code = native.keyCode
|
||||||
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||||
code == android.view.KeyEvent.KEYCODE_ENTER ||
|
code == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||||
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
|
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
|
||||||
code == android.view.KeyEvent.KEYCODE_SPACE
|
code == android.view.KeyEvent.KEYCODE_SPACE
|
||||||
if (activates) {
|
// Fires exactly once per press, on ACTION_DOWN: the UP is consumed too
|
||||||
onClick()
|
// (otherwise it leaks into an underlying clickable, which fires again —
|
||||||
true // consumed in preview -> the platform/card path never double-fires
|
// observed live: the Create button created a playlist twice), and key
|
||||||
} else false
|
// repeats are swallowed so holding OK cannot machine-gun the action.
|
||||||
} else false
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue