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. - 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.

View file

@ -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")

View file

@ -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,

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.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 {

View file

@ -257,6 +257,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
channelDetails = handle channelDetails = handle
channelName = displayName 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.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(

View file

@ -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
}
} }
} }