diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 496ca1f..3138989 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -3,15 +3,22 @@ package net.jeena.jtubetv.data import kotlinx.serialization.json.Json import net.jeena.jtubetv.data.api.HistoryElement 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.PlaylistCreatedResponse +import net.jeena.jtubetv.data.api.RatingBody import net.jeena.jtubetv.data.api.ServerConfigDto 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.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelListResponse import net.jeena.jtubetv.data.api.VideoDetailDto 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.auth.TokenStore 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 okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit @@ -233,6 +241,68 @@ class PeerTubeRepository( 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 = runCatching { + network.api.subscriptionsExist(listOf(handle))[handle] == true + } + + suspend fun subscribe(handle: String): Result = 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 = 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 = + runCatching { network.api.myRating(shortUuid).rating } + + suspend fun rate(shortUuid: String, rating: String): Result = + 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 = + runCatching { network.api.commentThreads(shortUuid, start, count) } + + /** Playlists owned by the logged-in user (their account handle). */ + suspend fun myPlaylists(): Result> = 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> = 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 = + runCatching { network.api.addToPlaylist(playlistId, AddToPlaylistBody(videoId)) } + + suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result = + runCatching { network.api.removeFromPlaylist(playlistId, elementId) } + + suspend fun createPlaylist(displayName: String): Result = 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. */ fun signOut() = store.clearAll() diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 4507861..86d8916 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -119,6 +119,8 @@ data class VideoDetailDto( val thumbnailPath: String? = null, val privacy: Labelled? = null, val views: Long = 0, + val likes: Long = 0, + val dislikes: Long = 0, val nsfw: Boolean = false, val channel: VideoChannelSummary, val files: List = emptyList(), @@ -176,4 +178,66 @@ data class UserDto( data class UserAccountDto( @SerialName("displayName") val displayName: String? = null, val avatars: List = emptyList(), -) \ No newline at end of file +) + +// ---- 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 = 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 = 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) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 1c5940a..2ef6672 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -1,10 +1,13 @@ package net.jeena.jtubetv.data.api +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.DELETE +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -120,4 +123,56 @@ interface PeerTubeApi { @GET("config") 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> + + @POST("users/me/subscriptions") + suspend fun subscribe(@Body body: SubscribeBody): Response + + @DELETE("users/me/subscriptions/{handle}") + suspend fun unsubscribe(@Path("handle") handle: String) + + @GET("users/me/subscriptions/exist") + suspend fun subscriptionsExist(@Query("uris[]") uris: List): Map + + @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) } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt new file mode 100644 index 0000000..43bc01c --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -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>(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>(emptyList()) } + var containedBy by remember { mutableStateOf>(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 { "" } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index b40baf9..0531d30 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -12,11 +12,14 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -78,10 +81,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable import net.jeena.jtubetv.R import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDetailDto 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 sealed interface PlayerUiState { @@ -273,7 +279,11 @@ private fun PlayerSurface( var consumedDownKey by remember { mutableStateOf(0) } // Overlay focus/position state for the controls (#4). 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) } + // Right sidebar (issue #10): "comments" | "playlists"; null = closed. + var sidebar by remember { mutableStateOf(null) } val player = remember { val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) @@ -446,10 +456,18 @@ private fun PlayerSurface( .onPreviewKeyEvent { ke -> val code = ke.nativeKeyEvent.keyCode 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 -> { - 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. paused -> false // 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 } else { - // UP/DOWN etc. reveal OUR controls, focus the Play button. + // UP/DOWN etc. reveal OUR controls, focus the bar. controlsVisible = true if (surfaceFocused) { runCatching { seekBarFocus.requestFocus() } @@ -497,7 +515,10 @@ private fun PlayerSurface( 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 // ready to render — metadata resolution alone (the Loading state) is not @@ -511,8 +532,8 @@ private fun PlayerSurface( contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } - // Controls: SmartTube-style (title + channel above, then the seek bar). - // Persistent when paused, transient while playing. + // Controls: SmartTube-style (title + channel above, then the seek bar + + // action row). Persistent when paused, transient while playing. if (paused || controlsVisible) { PausedControls( detail = s.detail, @@ -520,10 +541,28 @@ private fun PlayerSurface( positionMs = positionMs, playing = !paused, seekBarFocus = seekBarFocus, + actionBarFocus = actionBarFocus, onOpenChannel = onOpenChannel, + openSidebar = { mode -> + sidebar = mode + }, 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, // (handle, displayName) — opens the channel's videos page; exits the player. 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, ) { val durationMs = detail.duration * 1000L + val scope = androidx.compose.runtime.rememberCoroutineScope() + val channelFocus = remember { FocusRequester() } // While playing, the position text must keep counting. LaunchedEffect(playing) { while (playing) { @@ -583,7 +628,6 @@ private fun PausedControls( // Channel icon: a button — opens the channel's videos page (its avatar is // optional: federated channels may have none). var channelFocused by remember { mutableStateOf(false) } - val channelFocus = remember { FocusRequester() } var channelModifier: Modifier = Modifier.size(48.dp) channelModifier = channelModifier .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( positionMs = positionMs, durationMs = durationMs, seekBarFocus = seekBarFocus, player = player, - // UP climbs to the channel icon when it exists. 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. - Text( - text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + - formatDuration((durationMs / 1000).toInt()), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, + // 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(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 - .align(Alignment.End) - .padding(top = 8.dp), - ) + .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 = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + + formatDuration((durationMs / 1000).toInt()), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + } } } - /** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ @Composable private fun SeekBarRow( @@ -664,6 +792,7 @@ private fun SeekBarRow( seekBarFocus: FocusRequester, player: ExoPlayer, channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay) + actionRowFocus: FocusRequester? = null, // DOWN target: the action row modifier: Modifier = Modifier, ) { var barFocused by remember { mutableStateOf(false) } @@ -684,8 +813,14 @@ private fun SeekBarRow( KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { seekOverlay(player, positionMs, 10_000L, durationMs); true } - KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false - KeyEvent.KEYCODE_DPAD_DOWN -> true + KeyEvent.KEYCODE_DPAD_UP -> { + 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_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { @@ -766,4 +901,57 @@ private fun formatDuration(seconds: Int): String { h > 0 -> "%d:%02d:%02d".format(h, 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, + ) + } } \ No newline at end of file