player: Action row with subscribe, rating, comments and playlists

SmartTube keeps its action buttons under the seek bar — we had nothing.
The paused/controls overlay gained: subscribe (POST /users/me/
subscriptions with the channel URI, state via the bulk exist endpoint),
like/dislike with immediate count and fill feedback (PUT /videos/{uuid}/
rate), and right-sidebar panels for comments (GET /videos/{uuid}/
comment-threads, paged, read-only; the instance has comments disabled on
some videos, which surfaces as a friendly empty state) and playlists
(checkboxes add/remove the video via the playlist-elements endpoints, a
text field creates new ones). Opening a panel shrinks the video so it
stays fully visible on the left; BACK or LEFT closes again.

Two server quirks are handled: the subscription exist-check settles a
moment after the POST (optimistic update + delayed re-verify instead of
trusting an instant answer), and this emulator translates CENTER into
the platform click as well, so a single press could subscribe and
instantly unsubscribe — activation is now guarded per request.

Closes #10
This commit is contained in:
Jeena 2026-10-13 07:45:17 +00:00
parent 9f1c6d54c1
commit 9ca65c121b
5 changed files with 812 additions and 23 deletions

View file

@ -3,15 +3,22 @@ package net.jeena.jtubetv.data
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import net.jeena.jtubetv.data.api.HistoryElement import net.jeena.jtubetv.data.api.HistoryElement
import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.CommentThreadsResponse
import net.jeena.jtubetv.data.api.AddToPlaylistBody
import net.jeena.jtubetv.data.api.CreatePlaylistBody
import net.jeena.jtubetv.data.api.OAuthClient import net.jeena.jtubetv.data.api.OAuthClient
import net.jeena.jtubetv.data.api.PlaylistCreatedResponse
import net.jeena.jtubetv.data.api.RatingBody
import net.jeena.jtubetv.data.api.ServerConfigDto import net.jeena.jtubetv.data.api.ServerConfigDto
import net.jeena.jtubetv.data.api.PeerTubeApi import net.jeena.jtubetv.data.api.PeerTubeApi
import net.jeena.jtubetv.data.api.SubscribeBody
import net.jeena.jtubetv.data.api.TokenResponse import net.jeena.jtubetv.data.api.TokenResponse
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoChannelListResponse import net.jeena.jtubetv.data.api.VideoChannelListResponse
import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.api.VideoDetailDto
import net.jeena.jtubetv.data.api.VideoListResponse import net.jeena.jtubetv.data.api.VideoListResponse
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
import net.jeena.jtubetv.data.api.WatchingBody import net.jeena.jtubetv.data.api.WatchingBody
import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.network.PeerTubeNetwork import net.jeena.jtubetv.data.network.PeerTubeNetwork
@ -19,6 +26,7 @@ import net.jeena.jtubetv.data.network.apiUrlOf
import net.jeena.jtubetv.data.network.normalizeBaseUrl import net.jeena.jtubetv.data.network.normalizeBaseUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -233,6 +241,68 @@ class PeerTubeRepository(
fun hasSession(): Boolean = store.hasSession() fun hasSession(): Boolean = store.hasSession()
// ---- interactions (issue #10) ----------------------------------------
/** True if the user follows `channel@host`, false if not subscribed. */
suspend fun subscriptionExists(handle: String): Result<Boolean> = runCatching {
network.api.subscriptionsExist(listOf(handle))[handle] == true
}
suspend fun subscribe(handle: String): Result<Unit> = runCatching {
val r = network.api.subscribe(SubscribeBody(handle))
require(r.isSuccessful) { "subscribe failed: " + r.code() }
}.map { }
/** 404 (never subscribed) counts as unsubscribed. */
suspend fun unsubscribe(handle: String): Result<Unit> = runCatching {
network.api.unsubscribe(handle)
}.recoverCatching { e ->
if (e is retrofit2.HttpException && e.code() == 404) Unit else throw e
}
/** "none" | "like" | "dislike" */
suspend fun myRating(shortUuid: String): Result<String> =
runCatching { network.api.myRating(shortUuid).rating }
suspend fun rate(shortUuid: String, rating: String): Result<Unit> =
runCatching { network.api.rate(shortUuid, RatingBody(rating)) }
/** Top-level comment threads, newest first-ish (server default). */
suspend fun comments(shortUuid: String, start: Long, count: Int): Result<CommentThreadsResponse> =
runCatching { network.api.commentThreads(shortUuid, start, count) }
/** Playlists owned by the logged-in user (their account handle). */
suspend fun myPlaylists(): Result<List<VideoPlaylistSummaryDto>> = runCatching {
val account = store.accountName ?: throw IllegalStateException("No account")
val first = network.api.accountPlaylists(account, 0, 100)
val rest = if (first.total > first.data.size) {
network.api.accountPlaylists(account, first.data.size.toLong(), (first.total - first.data.size).toInt())
.data
} else emptyList()
first.data + rest
}
/** Playlist id → element id for the playlists containing the video (numeric video id). */
suspend fun playlistsContaining(videoId: Long): Result<Map<Long, Long>> = runCatching {
network.api.playlistsContaining(videoId)
.values
.flatten()
.mapNotNull { element -> element.playlistId?.let { element.elementId?.let { e -> it to e } } }
.toMap()
}
suspend fun addToPlaylist(playlistId: Long, videoId: Long): Result<Unit> =
runCatching { network.api.addToPlaylist(playlistId, AddToPlaylistBody(videoId)) }
suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result<Unit> =
runCatching { network.api.removeFromPlaylist(playlistId, elementId) }
suspend fun createPlaylist(displayName: String): Result<VideoPlaylistSummaryDto?> = runCatching {
val created = network.api.createPlaylist(CreatePlaylistBody(displayName))
.videoPlaylist ?: throw IllegalStateException("No playlist in response")
VideoPlaylistSummaryDto(id = created.id, displayName = displayName)
}
/** Full logout (feature request from UX review): forget URL, username and tokens. */ /** Full logout (feature request from UX review): forget URL, username and tokens. */
fun signOut() = store.clearAll() fun signOut() = store.clearAll()

View file

@ -119,6 +119,8 @@ data class VideoDetailDto(
val thumbnailPath: String? = null, val thumbnailPath: String? = null,
val privacy: Labelled? = null, val privacy: Labelled? = null,
val views: Long = 0, val views: Long = 0,
val likes: Long = 0,
val dislikes: Long = 0,
val nsfw: Boolean = false, val nsfw: Boolean = false,
val channel: VideoChannelSummary, val channel: VideoChannelSummary,
val files: List<VideoFile> = emptyList(), val files: List<VideoFile> = emptyList(),
@ -177,3 +179,65 @@ data class UserAccountDto(
@SerialName("displayName") val displayName: String? = null, @SerialName("displayName") val displayName: String? = null,
val avatars: List<Avatar> = emptyList(), val avatars: List<Avatar> = emptyList(),
) )
// ---- interactions under the video (issue #10) ----------------------------
/** One thread comment; `account` reuses [ChannelOwnerAccount] (name + displayName + avatars). */
@Serializable
data class CommentThreadDto(
val threadId: Long = 0,
val text: String = "",
val createdAt: String? = null,
@SerialName("totalReplies") val totalReplies: Int = 0,
val account: ChannelOwnerAccount? = null,
)
@Serializable
data class CommentThreadsResponse(
val total: Long = 0,
val data: List<CommentThreadDto> = emptyList(),
)
@Serializable
data class VideoPlaylistSummaryDto(
val id: Long,
@SerialName("displayName") val displayName: String,
val privacy: Labelled? = null,
)
@Serializable
data class VideoPlaylistListResponse(
val total: Long = 0,
val data: List<VideoPlaylistSummaryDto> = emptyList(),
)
/** Membership hit within one playlist (from videos-exist). */
@Serializable
data class PlaylistElementDto(
val playlistId: Long? = null,
@SerialName("playlistElementId") val elementId: Long? = null,
)
@Serializable
data class RateResponse(
val rating: String = "none",
)
// request bodies
@Serializable
data class SubscribeBody(val uri: String)
@Serializable
data class RatingBody(val rating: String)
@Serializable
data class AddToPlaylistBody(val videoId: Long)
@Serializable
data class CreatePlaylistBody(val displayName: String)
@Serializable
data class PlaylistCreatedDto(val id: Long = 0)
@Serializable
data class PlaylistCreatedResponse(val videoPlaylist: PlaylistCreatedDto? = null)

View file

@ -1,10 +1,13 @@
package net.jeena.jtubetv.data.api package net.jeena.jtubetv.data.api
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.Field import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.DELETE
import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@ -120,4 +123,56 @@ interface PeerTubeApi {
@GET("config") @GET("config")
suspend fun serverConfig(): ServerConfigDto suspend fun serverConfig(): ServerConfigDto
// ---- interactions (issue #10) ----------------------------------------
@GET("videos/{uuid}/comment-threads")
suspend fun commentThreads(
@Path("uuid") uuid: String,
@Query("start") start: Long,
@Query("count") count: Int,
): CommentThreadsResponse
@GET("accounts/{account}/video-playlists")
suspend fun accountPlaylists(
@Path("account") account: String,
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-createdAt",
): VideoPlaylistListResponse
@POST("video-playlists")
suspend fun createPlaylist(@Body body: CreatePlaylistBody): PlaylistCreatedResponse
@POST("video-playlists/{playlistId}/videos")
suspend fun addToPlaylist(
@Path("playlistId") playlistId: Long,
@Body body: AddToPlaylistBody,
)
@DELETE("video-playlists/{playlistId}/videos/{elementId}")
suspend fun removeFromPlaylist(
@Path("playlistId") playlistId: Long,
@Path("elementId") elementId: Long,
)
@GET("users/me/video-playlists/videos-exist")
suspend fun playlistsContaining(
@Query("videoIds") videoId: Long,
): Map<String, List<PlaylistElementDto>>
@POST("users/me/subscriptions")
suspend fun subscribe(@Body body: SubscribeBody): Response<Unit>
@DELETE("users/me/subscriptions/{handle}")
suspend fun unsubscribe(@Path("handle") handle: String)
@GET("users/me/subscriptions/exist")
suspend fun subscriptionsExist(@Query("uris[]") uris: List<String>): Map<String, Boolean>
@GET("users/me/videos/{uuid}/rating")
suspend fun myRating(@Path("uuid") uuid: String): RateResponse
@PUT("videos/{uuid}/rate")
suspend fun rate(@Path("uuid") uuid: String, @Body body: RatingBody)
} }

View file

@ -0,0 +1,412 @@
package net.jeena.jtubetv.ui.player
import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.onPreviewKeyEvent
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 coil.compose.AsyncImage
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
import net.jeena.jtubetv.data.api.CommentThreadDto
import net.jeena.jtubetv.data.api.VideoDetailDto
/**
* Right sidebar for the player (issue #10): "comments" or "playlists" panel.
* While open, the surface shrinks the video with `end` padding of
* [SIDEBAR_WIDTH]. Rows are D-pad navigable; BACK closes (handled by the
* surface) and LEFT over any row closes too.
*/
val SIDEBAR_WIDTH = 420.dp
private val SidebarBg = Color(0xFF0E0E0E)
private val TextMain = Color(0xFFE0E0E0)
private val TextDim = Color(0xFF9A9A9A)
private val Accent = Color(0xFFCE2B2B)
@Composable
fun PlayerSidebar(
mode: String, // "comments" | "playlists"
detail: VideoDetailDto,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxHeight()
.width(SIDEBAR_WIDTH)
.background(SidebarBg),
) {
Text(
text = if (mode == "playlists") "Playlists" else "Comments",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(20.dp),
)
if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss)
}
}
/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */
@Composable
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
val fr = remember { FocusRequester() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(120)
runCatching { fr.requestFocus() }
}
Text(
text = message,
color = TextDim,
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
.onPreviewKeyEvent { ke ->
if (ke.type == KeyEventType.KeyDown &&
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
) {
onDismiss()
true
} else false
}
.focusRequester(fr)
.focusable(),
)
}
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */
@Composable
private fun SidebarRow(
modifier: Modifier = Modifier,
onLeft: (() -> Unit)? = null,
focusRequester: FocusRequester? = null,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
var m = modifier
.fillMaxWidth()
.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()
true
} else false
}
}
m = m
.dpadCenterActivatable(onClick ?: {})
.focusable()
if (onClick != null) m = m.clickable(onClick = onClick)
Row(
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) { content() }
}
// ---- comments --------------------------------------------------------------
@Composable
private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
val repository = AppGraph.repository
// Comments load once per opening; paging via list end detection.
var items by remember { mutableStateOf<List<CommentThreadDto>>(emptyList()) }
var total by remember { mutableStateOf(0L) }
var start by remember { mutableStateOf(0L) }
var loading by remember { mutableStateOf(true) }
var failed by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
val firstRow = remember { FocusRequester() }
suspend fun fetchNext() {
loading = true
repository.comments(detail.shortUUID, start, 20).fold(
onSuccess = { r ->
items = items + r.data
start = items.size.toLong()
total = r.total
},
onFailure = {
// Only fail the FIRST page; later page errors keep what we have.
if (items.isEmpty()) failed = true
},
)
loading = false
}
LaunchedEffect(Unit) { fetchNext() }
val lastVisibleIndex by remember {
androidx.compose.runtime.derivedStateOf {
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
}
}
LaunchedEffect(lastVisibleIndex, total) {
if (failed || loading || total <= 0) return@LaunchedEffect
val last = lastVisibleIndex ?: return@LaunchedEffect
if (start < total && last >= items.size - 2) fetchNext()
}
// D-pad focus: land on the first comment when ready.
LaunchedEffect(loading, failed, items.size) {
if (!loading && !failed && items.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { firstRow.requestFocus() }
}
}
Box(Modifier.fillMaxSize()) {
when {
loading && items.isEmpty() -> SidebarFallback("Loading…", onDismiss)
failed -> SidebarFallback("Comments are not available here.", onDismiss)
items.isEmpty() -> SidebarFallback("No comments yet.", onDismiss)
else ->
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items.forEachIndexed { index, comment ->
item(key = "c" + comment.threadId) {
CommentRow(
comment = comment,
onLeft = onDismiss,
focusRequester = if (index == 0) firstRow else null,
)
}
}
if (start < total) {
item(key = "more") {
Text(
"Loading more…",
color = TextDim,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(20.dp),
)
}
}
}
}
}
}
@Composable
private fun CommentRow(
comment: CommentThreadDto,
onLeft: () -> Unit,
focusRequester: FocusRequester? = null,
) {
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
SidebarRow(
modifier = Modifier.padding(vertical = 4.dp),
onLeft = onLeft,
focusRequester = focusRequester,
) {
if (avatar != null) {
AsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(36.dp).clip(CircleShape),
)
}
Column(Modifier.padding(start = 10.dp)) {
Text(
text = (comment.account?.displayName ?: "?") +
" " + formatDateShort(comment.createdAt),
color = TextDim,
style = MaterialTheme.typography.labelSmall,
)
Text(
text = comment.text,
color = TextMain,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp),
)
if (comment.totalReplies > 0) {
Text(
text = comment.totalReplies.toString() + " replies",
color = TextDim,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
// ---- playlists ---------------------------------------------------------------
@Composable
private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
val repository = AppGraph.repository
val scope = rememberCoroutineScope()
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
var containedBy by remember { mutableStateOf<Map<Long, Long>>(emptyMap()) }
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() }
suspend fun refreshHits() {
repository.playlistsContaining(detail.id).fold(
onSuccess = { containedBy = it },
onFailure = { },
)
}
LaunchedEffect(detail.id) {
repository.myPlaylists().fold(
onSuccess = { playlists = it },
onFailure = { failed = true },
)
refreshHits()
loading = false
}
fun toggle(playlist: VideoPlaylistSummaryDto) {
val elementId = containedBy[playlist.id]
scope.launch {
if (elementId != null) {
repository.removeFromPlaylist(playlist.id, elementId)
} else {
repository.addToPlaylist(playlist.id, detail.id)
}
// Membership checks settle server-side; re-check after a moment.
kotlinx.coroutines.delay(1000)
refreshHits()
}
}
fun create() {
val name = newName.trim()
if (name.isEmpty()) return
scope.launch {
repository.createPlaylist(name).onSuccess { playlist ->
if (playlist != null) {
playlists = listOf(playlist) + playlists
newName = ""
}
}
}
}
LaunchedEffect(loading) {
if (!loading && !failed && playlists.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { rowsFocus.requestFocus() }
}
}
Column(Modifier.fillMaxSize()) {
// New playlist: text field + create button.
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(
value = newName,
onValueChange = { newName = it },
singleLine = true,
label = { Text("New playlist") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
modifier = Modifier
.weight(1f)
.focusRequester(inputFocus)
.padding(start = 16.dp),
)
Button(
onClick = { create() },
modifier = Modifier.padding(start = 10.dp, end = 16.dp),
) { Text("Create") }
}
when {
loading -> SidebarFallback("Loading…", onDismiss)
failed -> SidebarFallback("Could not load playlists", onDismiss)
playlists.isEmpty() -> SidebarFallback("No playlists yet.", onDismiss)
else ->
LazyColumn(Modifier.fillMaxSize()) {
playlists.forEachIndexed { index, playlist ->
val checked = containedBy.containsKey(playlist.id)
item(key = "p" + playlist.id) {
SidebarRow(
onLeft = onDismiss,
onClick = { toggle(playlist) },
focusRequester = if (index == 0) rowsFocus else null,
) {
PlaylistCheckbox(checked)
Text(
text = playlist.displayName,
color = TextMain,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 12.dp),
maxLines = 1,
)
}
}
}
}
}
}
}
@Composable
private fun PlaylistCheckbox(checked: Boolean) {
Box(
modifier = Modifier
.size(22.dp)
.clip(RoundedCornerShape(5.dp))
.background(if (checked) Accent else Color(0x33FFFFFF)),
)
}
private fun formatDateShort(iso: String?): String {
if (iso.isNullOrBlank()) return ""
return runCatching {
java.time.ZonedDateTime.ofInstant(
java.time.Instant.parse(iso),
java.time.ZoneId.systemDefault(),
).format(java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy", java.util.Locale.getDefault()))
}.getOrElse { "" }
}

View file

@ -12,11 +12,14 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -78,10 +81,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
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.R import net.jeena.jtubetv.R
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.api.VideoDetailDto
import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.ui.player.PlayerSidebar
import net.jeena.jtubetv.ui.player.SIDEBAR_WIDTH
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
sealed interface PlayerUiState { sealed interface PlayerUiState {
@ -273,7 +279,11 @@ private fun PlayerSurface(
var consumedDownKey by remember { mutableStateOf(0) } var consumedDownKey by remember { mutableStateOf(0) }
// Overlay focus/position state for the controls (#4). // Overlay focus/position state for the controls (#4).
val seekBarFocus = remember { FocusRequester() } val seekBarFocus = remember { FocusRequester() }
// First button of the action row (focus target when the sidebar closes).
val actionBarFocus = remember { FocusRequester() }
val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) }
// Right sidebar (issue #10): "comments" | "playlists"; null = closed.
var sidebar by remember { mutableStateOf<String?>(null) }
val player = remember { val player = remember {
val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) val hostIsInstance = viewModel.isInstanceMedia(s.playUrl)
@ -446,10 +456,18 @@ private fun PlayerSurface(
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
val code = ke.nativeKeyEvent.keyCode val code = ke.nativeKeyEvent.keyCode
when { when {
// BACK/Esc leave the player in any state, from any focus. // BACK/Esc: close the sidebar first, else leave the player.
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
onBack(); true if (sidebar != null) {
sidebar = null
runCatching { actionBarFocus.requestFocus() }
} else {
onBack()
} }
true
}
// Sidebar open: our rows navigate (they eat LEFT too).
sidebar != null -> false
// While paused our overlay has focus and handles everything. // While paused our overlay has focus and handles everything.
paused -> false paused -> false
// Transient controls hold focus while playing: let them move it. // Transient controls hold focus while playing: let them move it.
@ -462,7 +480,7 @@ private fun PlayerSurface(
) { ) {
consumedDownKey = 0 // trailing UP of a handled press consumedDownKey = 0 // trailing UP of a handled press
} else { } else {
// UP/DOWN etc. reveal OUR controls, focus the Play button. // UP/DOWN etc. reveal OUR controls, focus the bar.
controlsVisible = true controlsVisible = true
if (surfaceFocused) { if (surfaceFocused) {
runCatching { seekBarFocus.requestFocus() } runCatching { seekBarFocus.requestFocus() }
@ -497,7 +515,10 @@ private fun PlayerSurface(
setOnClickListener { controlsVisible = true } setOnClickListener { controlsVisible = true }
} }
}, },
modifier = Modifier.fillMaxSize(), // With the sidebar open the video shrinks to the left (issue #10).
modifier = Modifier
.fillMaxSize()
.padding(end = if (sidebar != null) SIDEBAR_WIDTH else 0.dp),
) )
// Cover the picture with the loading indicator until the player is really // Cover the picture with the loading indicator until the player is really
// ready to render — metadata resolution alone (the Loading state) is not // ready to render — metadata resolution alone (the Loading state) is not
@ -511,8 +532,8 @@ private fun PlayerSurface(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
} }
// Controls: SmartTube-style (title + channel above, then the seek bar). // Controls: SmartTube-style (title + channel above, then the seek bar +
// Persistent when paused, transient while playing. // action row). Persistent when paused, transient while playing.
if (paused || controlsVisible) { if (paused || controlsVisible) {
PausedControls( PausedControls(
detail = s.detail, detail = s.detail,
@ -520,10 +541,28 @@ private fun PlayerSurface(
positionMs = positionMs, positionMs = positionMs,
playing = !paused, playing = !paused,
seekBarFocus = seekBarFocus, seekBarFocus = seekBarFocus,
actionBarFocus = actionBarFocus,
onOpenChannel = onOpenChannel, onOpenChannel = onOpenChannel,
openSidebar = { mode ->
sidebar = mode
},
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
) )
} }
// Right sidebar (comments / playlists); the video is shrunk to its left.
sidebar?.let { mode ->
PlayerSidebar(
mode = mode,
detail = s.detail,
onDismiss = {
sidebar = null
runCatching { actionBarFocus.requestFocus() }
},
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(top = 48.dp),
)
}
} }
} }
@ -556,9 +595,15 @@ private fun PausedControls(
seekBarFocus: FocusRequester, seekBarFocus: FocusRequester,
// (handle, displayName) — opens the channel's videos page; exits the player. // (handle, displayName) — opens the channel's videos page; exits the player.
onOpenChannel: (String, String) -> Unit = { _, _ -> }, 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 = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val durationMs = detail.duration * 1000L val durationMs = detail.duration * 1000L
val scope = androidx.compose.runtime.rememberCoroutineScope()
val channelFocus = 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) {
@ -583,7 +628,6 @@ private fun PausedControls(
// Channel icon: a button — opens the channel's videos page (its avatar is // Channel icon: a button — opens the channel's videos page (its avatar is
// optional: federated channels may have none). // optional: federated channels may have none).
var channelFocused by remember { mutableStateOf(false) } var channelFocused by remember { mutableStateOf(false) }
val channelFocus = remember { FocusRequester() }
var channelModifier: Modifier = Modifier.size(48.dp) var channelModifier: Modifier = Modifier.size(48.dp)
channelModifier = channelModifier channelModifier = channelModifier
.focusRequester(channelFocus) .focusRequester(channelFocus)
@ -633,29 +677,113 @@ private fun PausedControls(
) )
} }
} }
// The seek bar is the only control: focused = scrub, OK = play/pause. // The seek bar is the only playback control: focused = scrub, OK toggles.
SeekBarRow( SeekBarRow(
positionMs = positionMs, positionMs = positionMs,
durationMs = durationMs, durationMs = durationMs,
seekBarFocus = seekBarFocus, seekBarFocus = seekBarFocus,
player = player, player = player,
// UP climbs to the channel icon when it exists.
channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null, channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null,
modifier = Modifier.padding(top = 16.dp), actionRowFocus = actionBarFocus,
modifier = Modifier.padding(top = 12.dp),
) )
// Position / duration under the bar, right-aligned. // Action row under the red line (issue #10): subscribe, like/dislike,
// 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) }
var rating by remember { mutableStateOf("none") }
// Counts update optimistically alongside the rating (the DTO is static).
var likeCount by remember { mutableStateOf(detail.likes) }
var dislikeCount by remember { mutableStateOf(detail.dislikes) }
val channelHandle = detail.channel.name + "@" + (detail.channel.host ?: "")
LaunchedEffect(detail.shortUUID) {
repository.subscriptionExists(channelHandle).onSuccess { subscribed = it }
repository.myRating(detail.shortUUID).onSuccess { rating = it }
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerActionButton(
label = when (subscribed) {
true -> "Subscribed"
false -> "Subscribe"
else -> "Subscribe"
},
filled = subscribed == true,
focusRequester = actionBarFocus,
) { done ->
scope.launch {
val wasSubscribed = subscribed == true
val ok = if (wasSubscribed) {
repository.unsubscribe(channelHandle).isSuccess
} else {
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.
subscribed = !wasSubscribed
kotlinx.coroutines.delay(1500)
repository.subscriptionExists(channelHandle).onSuccess { subscribed = it }
}
done()
}
}
PlayerActionButton(
label = "👍 " + likeCount,
filled = rating == "like",
) { done ->
scope.launch {
val hadLiked = rating == "like"
val target = if (hadLiked) "none" else "like"
if (repository.rate(detail.shortUUID, target).isSuccess) {
rating = target
likeCount = if (hadLiked) likeCount - 1 else likeCount + 1
}
done()
}
}
PlayerActionButton(
label = "👎 " + dislikeCount,
filled = rating == "dislike",
) { done ->
scope.launch {
val hadDisliked = rating == "dislike"
val target = if (hadDisliked) "none" else "dislike"
if (repository.rate(detail.shortUUID, target).isSuccess) {
rating = target
dislikeCount = if (hadDisliked) dislikeCount - 1 else dislikeCount + 1
}
done()
}
}
PlayerActionButton(
label = "Comments",
) { done ->
done()
openSidebar("comments")
}
PlayerActionButton(
label = "Playlists",
) { done ->
done()
openSidebar("playlists")
}
Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
formatDuration((durationMs / 1000).toInt()), formatDuration((durationMs / 1000).toInt()),
color = Color(0xFFCCCCCC), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.End)
.padding(top = 8.dp),
) )
} }
} }
}
/** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ /** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */
@Composable @Composable
private fun SeekBarRow( private fun SeekBarRow(
@ -664,6 +792,7 @@ private fun SeekBarRow(
seekBarFocus: FocusRequester, seekBarFocus: FocusRequester,
player: ExoPlayer, player: ExoPlayer,
channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay) channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay)
actionRowFocus: FocusRequester? = null, // DOWN target: the action row
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var barFocused by remember { mutableStateOf(false) } var barFocused by remember { mutableStateOf(false) }
@ -684,8 +813,14 @@ private fun SeekBarRow(
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
seekOverlay(player, positionMs, 10_000L, durationMs); true seekOverlay(player, positionMs, 10_000L, durationMs); true
} }
KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false KeyEvent.KEYCODE_DPAD_UP -> {
KeyEvent.KEYCODE_DPAD_DOWN -> true if (channelFocus != null) runCatching { channelFocus.requestFocus() }
true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
if (actionRowFocus != null) runCatching { actionRowFocus.requestFocus() }
true
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> {
@ -767,3 +902,56 @@ private fun formatDuration(seconds: Int): String {
else -> "%d:%02d".format(m, s) else -> "%d:%02d".format(m, s)
} }
} }
/** Pill button of the video action row (issue #10): text or emoji-labelled; the
* subscribed/liked states get a solid accent background. */
@Composable
private fun PlayerActionButton(
label: String,
filled: Boolean = false,
focusRequester: 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,
) {
var focused by remember { mutableStateOf(false) }
// One activation per press: on some devices CENTER also triggers the platform
// click; without a guard the double event would subscribe and immediately
// unsubscribe (observed live). The guard also blocks spammage while a request
// is in flight.
var inFlight by remember { mutableStateOf(false) }
fun activate() {
if (inFlight) return
inFlight = true
onClick { inFlight = false }
}
var m: Modifier = Modifier
if (focusRequester != null) m = m.focusRequester(focusRequester)
m = m
.onFocusChanged { focused = it.isFocused }
.dpadCenterActivatable({ activate() })
.focusable()
.background(
when {
focused -> Color(0x55FFFFFF)
filled -> Color(0x33CE2B2B)
else -> Color(0x22FFFFFF)
},
RoundedCornerShape(20.dp),
)
.then(
if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp))
else Modifier
)
// Click only for real pointer input; the key activation above is guarded.
.pointerInput(Unit) { detectTapGestures { activate() } }
Box(modifier = m.padding(horizontal = 14.dp, vertical = 8.dp)) {
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
}
}