player: wire the D-pad through the action row, panels and playlist deletion
The action-row buttons lost their modifier in the icon-only rework and
became unfocusable, so D-pad DOWN off the paused seek bar landed nowhere
(requestFocus failed with "not initialized"). The modifier is attached
to the button again and the seek bar ↔ buttons directions are wired
explicitly, because the default focus search skips the bar.
Pill visuals use the new state language: no border, gray pill = focused,
red glyph = subscribed/rated; the sidebar panels keep the D-pad inside
their lists (consumed at the edges). BACK/Esc acts on ACTION_DOWN only —
its trailing UP saw the panel already closed and exited the video — and
panels return focus to the pill that opened them.
Playlist deletion is a requested scope addition: holding OK on a row
asks via a dialog (DELETE /video-playlists/{id}); the toggle moves to
key release so the hold cannot double-act. Holding also exposed an
activation bug: the CENTER helper consumed only ACTION_DOWN and the
release leaked into the underlying clickable — now consumed, repeats
swallowed, so Create (and the cards) fire exactly once per press.
An info panel shows the video description with clickable links: inline
(pointer) plus a focusable link row list (D-pad); PeerTube video and
channel links play/drill in-app, other links open the browser.
Closes #12
This commit is contained in:
parent
c70d98ac64
commit
f5b37dd21d
7 changed files with 498 additions and 112 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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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