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:
Jeena 2026-10-13 14:25:21 +00:00
parent c70d98ac64
commit f5b37dd21d
7 changed files with 498 additions and 112 deletions

View file

@ -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.
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
- 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.

View file

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

View file

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

View file

@ -35,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.onFocusChanged
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type
@ -43,10 +50,15 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.foundation.border
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
import net.jeena.jtubetv.ui.screens.list.dpadCenterTwoStage
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
import net.jeena.jtubetv.data.api.CommentThreadDto
import net.jeena.jtubetv.data.api.VideoDetailDto
@ -66,9 +78,13 @@ private val Accent = Color(0xFFCE2B2B)
@Composable
fun PlayerSidebar(
mode: String, // "comments" | "playlists"
mode: String, // "comments" | "playlists" | "info"
detail: VideoDetailDto,
onDismiss: () -> Unit,
// PeerTube video links from the info panel's description.
onOpenVideo: (String) -> Unit = {},
// PeerTube channel links (drill down to the channel's page).
onOpenChannel: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(
@ -78,15 +94,148 @@ fun PlayerSidebar(
.background(SidebarBg),
) {
Text(
text = if (mode == "playlists") "Playlists" else "Comments",
text = when (mode) {
"playlists" -> "Playlists"
"info" -> "Info"
else -> "Comments"
},
color = Color.White,
style = MaterialTheme.typography.titleMedium,
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). */
@Composable
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
@ -102,11 +251,17 @@ private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
.fillMaxWidth()
.padding(20.dp)
.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
) {
onDismiss()
true
if (ke.type == KeyEventType.KeyDown) {
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
onDismiss(); true
}
// A one-row panel: no navigation possible — stay put
// instead of letting focus escape to the action row.
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_RIGHT -> true
else -> false
}
} else false
}
.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
private fun SidebarRow(
modifier: Modifier = Modifier,
onLeft: (() -> Unit)? = null,
onUp: (() -> Unit)? = null,
atTop: Boolean = false,
atBottom: Boolean = false,
focusRequester: FocusRequester? = null,
onClick: (() -> Unit)? = null,
// Long press on OK opens a context action (e.g. playlist removal) instead
// of the click — the click itself fires on key release then.
onLongPress: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@ -129,19 +294,31 @@ private fun SidebarRow(
.onFocusChanged { focused = it.isFocused }
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
if (focusRequester != null) m = m.focusRequester(focusRequester)
if (onLeft != null) {
m = m.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
) {
onLeft()
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
if (onLeft != null) onLeft()
onLeft != null
}
// No focusable beyond the list's ends in this direction:
// swallowing the key keeps focus inside the panel.
KeyEvent.KEYCODE_DPAD_DOWN -> atBottom
KeyEvent.KEYCODE_DPAD_UP -> {
if (onUp != null) {
onUp()
true
} else false
} else atTop
}
else -> false
}
}
m = m
.dpadCenterActivatable(onClick ?: {})
.focusable()
m = if (onLongPress != null) {
m.dpadCenterTwoStage(onClick ?: {}, onLongPress)
} else {
m.dpadCenterActivatable(onClick ?: {})
}
m = m.focusable()
if (onClick != null) m = m.clickable(onClick = onClick)
Row(
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
@ -212,6 +389,8 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
CommentRow(
comment = comment,
onLeft = onDismiss,
atTop = index == 0,
atBottom = index == items.size - 1,
focusRequester = if (index == 0) firstRow else null,
)
}
@ -235,12 +414,16 @@ private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
private fun CommentRow(
comment: CommentThreadDto,
onLeft: () -> Unit,
atTop: Boolean = false,
atBottom: Boolean = false,
focusRequester: FocusRequester? = null,
) {
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
SidebarRow(
modifier = Modifier.padding(vertical = 4.dp),
onLeft = onLeft,
atTop = atTop,
atBottom = atBottom,
focusRequester = focusRequester,
) {
if (avatar != null) {
@ -283,19 +466,34 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
var containedBy by remember { mutableStateOf<Map<Long, Long>>(emptyMap()) }
// Optimistic flips: shown immediately when toggled, then folded away once
// the server's membership check has settled (a roundtrip can take a
// second — pressing without instant feedback feels broken).
var addedOptimistically by remember { mutableStateOf<Set<Long>>(emptySet()) }
var removedOptimistically by remember { mutableStateOf<Set<Long>>(emptySet()) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
val inputFocus = remember { FocusRequester() }
val rowsFocus = remember { FocusRequester() }
val createBtnFocus = remember { FocusRequester() }
var busy by remember { mutableStateOf(false) }
suspend fun refreshHits() {
repository.playlistsContaining(detail.id).fold(
onSuccess = { containedBy = it },
onSuccess = {
containedBy = it
addedOptimistically = emptySet()
removedOptimistically = emptySet()
},
onFailure = { },
)
}
fun checked(playlistId: Long): Boolean =
(containedBy.containsKey(playlistId) && playlistId !in removedOptimistically) ||
playlistId in addedOptimistically
LaunchedEffect(detail.id) {
repository.myPlaylists().fold(
onSuccess = { playlists = it },
@ -306,16 +504,28 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
}
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 {
if (elementId != null) {
repository.removeFromPlaylist(playlist.id, elementId)
if (inside) {
val elementId = containedBy[playlist.id] ?: return@launch
if (repository.removeFromPlaylist(playlist.id, elementId).isSuccess) {
removedOptimistically = removedOptimistically + playlist.id
}
} 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.
kotlinx.coroutines.delay(1000)
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) {
if (!loading && !failed && playlists.isNotEmpty()) {
kotlinx.coroutines.delay(150)
@ -354,11 +585,35 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
modifier = Modifier
.weight(1f)
.focusRequester(inputFocus)
// The field is the D-pad entry point to creating a playlist:
// UP from the first list row lands here (opens the IME) and
// DOWN returns to the list.
.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown) {
when (ke.nativeKeyEvent.keyCode) {
// DOWN returns to the list; RIGHT goes to the
// Create button (single-line TV field — cursor
// moves are worth less than being able to leave).
KeyEvent.KEYCODE_DPAD_DOWN -> {
runCatching { rowsFocus.requestFocus() }
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
runCatching { createBtnFocus.requestFocus() }
true
}
else -> false
}
} else false
}
.padding(start = 16.dp),
)
Button(
onClick = { create() },
modifier = Modifier.padding(start = 10.dp, end = 16.dp),
modifier = Modifier
.padding(start = 10.dp, end = 16.dp)
.focusRequester(createBtnFocus)
.dpadCenterActivatable({ create() }),
) { Text("Create") }
}
when {
@ -368,11 +623,17 @@ private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
else ->
LazyColumn(Modifier.fillMaxSize()) {
playlists.forEachIndexed { index, playlist ->
val checked = containedBy.containsKey(playlist.id)
val checked = checked(playlist.id)
item(key = "p" + playlist.id) {
SidebarRow(
onLeft = onDismiss,
onUp = if (index == 0) {
{ runCatching { inputFocus.requestFocus() } }
} else null,
atTop = index == 0,
atBottom = index == playlists.size - 1,
onClick = { toggle(playlist) },
onLongPress = { requestRemove(playlist) },
focusRequester = if (index == 0) rowsFocus else null,
) {
PlaylistCheckbox(checked)
@ -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
@ -397,8 +685,25 @@ private fun PlaylistCheckbox(checked: Boolean) {
modifier = Modifier
.size(22.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 {

View file

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

View file

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

View file

@ -1,7 +1,11 @@
package net.jeena.jtubetv.ui.screens.list
import android.view.KeyEvent
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.key.onPreviewKeyEvent
@ -14,16 +18,58 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
*/
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
val code = ke.nativeKeyEvent.keyCode
val native = ke.nativeKeyEvent
val code = native.keyCode
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
code == android.view.KeyEvent.KEYCODE_ENTER ||
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == android.view.KeyEvent.KEYCODE_SPACE
// Fires exactly once per press, on ACTION_DOWN: the UP is consumed too
// (otherwise it leaks into an underlying clickable, which fires again —
// observed live: the Create button created a playlist twice), and key
// repeats are swallowed so holding OK cannot machine-gun the action.
when (native.action) {
android.view.KeyEvent.ACTION_DOWN -> {
if (activates) {
onClick()
true // consumed in preview -> the platform/card path never double-fires
} else false
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
}
}
}