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:
parent
9f1c6d54c1
commit
9ca65c121b
5 changed files with 812 additions and 23 deletions
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
412
app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
Normal file
412
app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
Normal 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 { "" }
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
Text(
|
// comments + playlists open the right sidebar; times on the right.
|
||||||
text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
|
val repository = AppGraph.repository
|
||||||
formatDuration((durationMs / 1000).toInt()),
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
color = Color(0xFFCCCCCC),
|
var subscribed by remember { mutableStateOf<Boolean?>(null) }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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
|
modifier = Modifier
|
||||||
.align(Alignment.End)
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp),
|
.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. */
|
/** 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue