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 3138989..496ca1f 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -3,22 +3,15 @@ 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 @@ -26,7 +19,6 @@ 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 @@ -241,68 +233,6 @@ 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 86d8916..4507861 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,8 +119,6 @@ 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(), @@ -178,66 +176,4 @@ data class UserDto( data class UserAccountDto( @SerialName("displayName") val displayName: String? = null, val avatars: List = 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 = 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 +) \ 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 2ef6672..1c5940a 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,13 +1,10 @@ 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 @@ -123,56 +120,4 @@ 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/data/network/Network.kt b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt index e529cde..dd2b68d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt @@ -161,4 +161,4 @@ class TokenAuthenticator( } return count } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 7a45d46..8bc14bd 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository -import net.jeena.jtubetv.ui.list.Snapshots sealed interface SessionState { data object Loading : SessionState @@ -84,8 +83,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() - // Snapshots contain account-private listings — invalidate them. - AppGraph.appContext?.let { Snapshots.clearAll(it) } _userProfile.value = null _sessionState.value = SessionState.NeedsSetup sessionEpoch.value += 1 diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt index 7a17eb7..3429a76 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -25,30 +25,6 @@ abstract class PagedListViewModel : ViewModel() { var loadingInitial by mutableStateOf(true) private set - /** - * Show an instant (possibly stale) snapshot before the network load lands - * (issue #9): no spinner, content paints immediately and [refresh]-style - * pages replace it silently afterwards. - */ - protected fun showInstant(snapshot: List) { - if (items.isNotEmpty() || snapshot.isEmpty()) return - items = snapshot - exhausted = false - loadingInitial = false - viewModelScope.launch { - // Replace the (possibly stale) first page with fresh network data. - runCatching { fetchAndReplace() } - } - } - - private suspend fun fetchAndReplace() { - val fresh = fetch(0, pageSize).getOrNull() ?: return - exhausted = fresh.size < pageSize - items = fresh - initialError = null - onChunkLoaded(fresh) - } - protected abstract suspend fun fetch(start: Long, count: Int): Result> /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt deleted file mode 100644 index f2fc26f..0000000 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt +++ /dev/null @@ -1,100 +0,0 @@ -package net.jeena.jtubetv.ui.list - -import android.content.Context -import kotlinx.serialization.builtins.ListSerializer -import net.jeena.jtubetv.data.api.VideoChannelDto -import net.jeena.jtubetv.data.api.VideoDto - -/** - * Disk snapshots of the last successfully loaded lists (issue #9): the app - * shows them instantly on the next cold start while the network refresh runs - * in the background, so navigation never waits for the API again on the first - * paint. Storage lives in cacheDir (the OS may evict it) and is invalidated - * implicitly: every successful chunk overwrites the snapshot, logout clears it. - */ -object Snapshots { - - private val json = net.jeena.jtubetv.data.network.peertubeJson - private const val DIR = "list-snapshots" - - private fun file(context: Context, key: String) = - java.io.File(context.cacheDir, DIR + "/" + key.replace(Regex("[^A-Za-z0-9._-]"), "_") + ".json") - - private fun write(context: Context, key: String, content: String) { - val f = file(context, key) - f.parentFile?.mkdirs() - f.writeText(content) - } - - private fun read(context: Context, key: String): String? = - file(context, key).takeIf { it.exists() }?.readText() - - fun saveVideos(context: Context, key: String, videos: List) = runCatching { - write(context, key, json.encodeToString(ListSerializer(VideoDto.serializer()), videos)) - } - - fun loadVideos(context: Context, key: String): List? = - read(context, key)?.let { raw -> - runCatching { json.decodeFromString(ListSerializer(VideoDto.serializer()), raw) }.getOrNull() - } - - fun saveUserGroups( - context: Context, - key: String, - rows: List, - ) = runCatching { - write( - context, - key, - json.encodeToString( - ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()), - rows, - ), - ) - } - - fun loadUserGroups(context: Context, key: String): List? = - read(context, key)?.let { raw -> - runCatching { - json.decodeFromString( - ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()), - raw, - ) - }.getOrNull() - } - - fun saveChannelHeader(context: Context, handle: String, channel: VideoChannelDto) = runCatching { - write(context, "chan-" + handle, json.encodeToString(VideoChannelDto.serializer(), channel)) - } - - fun saveContinue(context: Context, key: String, items: List) = runCatching { - write( - context, - key, - json.encodeToString( - ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()), - items, - ), - ) - } - - fun loadContinue(context: Context, key: String): List? = - read(context, key)?.let { raw -> - runCatching { - json.decodeFromString( - ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()), - raw, - ) - }.getOrNull() - } - - fun loadChannelHeader(context: Context, handle: String): VideoChannelDto? = - read(context, "chan-" + handle)?.let { raw -> - runCatching { json.decodeFromString(VideoChannelDto.serializer(), raw) }.getOrNull() - } - - /** Forget everything (logout / account switch). */ - fun clearAll(context: Context) { - java.io.File(context.cacheDir, DIR).deleteRecursively() - } -} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 4535e21..dca398c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository -import net.jeena.jtubetv.ui.list.Snapshots /** Where the list's content comes from. */ sealed interface ListSource { @@ -42,37 +41,6 @@ class VideoListViewModel( ) private set - // Snapshot caching (issue #9): live content lists only — Search results and - // History rows (which carry extra watch metadata) must not persist. - private val shouldSkipSnapshot = - source is ListSource.Search || source is ListSource.History - - private fun cacheKey(): String = when (val s = source) { - is ListSource.Channel -> "channel-" + s.handle - else -> s::class.java.simpleName - } - - init { - val context = AppGraph.appContext - if (context != null && !shouldSkipSnapshot) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - showInstant(videos.map { VideoListItem.Video(it) }) - } - // Channel page banner: restore the saved one instantly too. - val s = source - if (s is ListSource.Channel) { - Snapshots.loadChannelHeader(context, s.handle)?.let { header -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - channelHeader = header - } - } - } - } - } - } - override fun onChunkLoaded(chunk: List) { ImagePrefetcher.videoThumbnails( chunk.mapNotNull { @@ -82,20 +50,6 @@ class VideoListViewModel( } } ) - // Persist the freshly loaded content so the next app start paints - // instantly (issue #9). - if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) { - val context = AppGraph.appContext - if (context != null) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveVideos( - context, - cacheKey(), - items.filterIsInstance().take(50).map { it.video }, - ) - } - } - } } override suspend fun fetch(start: Long, count: Int): Result> { @@ -120,24 +74,7 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { - // Instant restore from the snapshot, then a fresh fetch (issue #9). - val context = AppGraph.appContext - if (context != null) { - Snapshots.loadChannelHeader(context, s.handle)?.let { snap -> - if (channelHeader == null) channelHeader = snap - } - } - viewModelScope.launch { - repository.videoChannel(s.handle).getOrNull()?.let { header -> - channelHeader = header - val ctx = AppGraph.appContext - if (ctx != null) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveChannelHeader(ctx, s.handle, header) - } - } - } - } + viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() } } } 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 deleted file mode 100644 index 43bc01c..0000000 --- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt +++ /dev/null @@ -1,412 +0,0 @@ -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/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index d6f0d2d..a97a119 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -59,7 +59,6 @@ import net.jeena.jtubetv.data.api.Avatar import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto -import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg @@ -79,7 +78,6 @@ import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable * the full listing (max 100/page) and fetch per-channel stats (videos + sum of * video views = plays) with small throttled calls. */ -@kotlinx.serialization.Serializable data class UserGroup( val ownerName: String, val label: String, @@ -89,10 +87,6 @@ data class UserGroup( class ChannelsViewModel(private val store: TokenStore) : ViewModel() { - private val snapshotContext = AppGraph.appContext - private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_") - private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) } - var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) @@ -121,16 +115,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private set private val repo: PeerTubeRepository = AppGraph.repository - private val statsMap = HashMap() - init { - // Snapshot restore (issue #9): paint the last session's channel rows - // instantly; the streaming reload below then updates stats in place. - if (snapshotRows != null) { - userRows = snapshotRows - loading = false - } - } + private val statsMap = HashMap() fun load(force: Boolean = false) { @@ -255,12 +241,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) - // Persist for the next cold start (issue #9). - snapshotContext?.let { ctx -> - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveUserGroups(ctx, snapshotKey, userRows) - } - } Log.w( "JTube", "order now: " + userRows.take(6).joinToString(" | ") { g -> diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 3def3a1..74cf99a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -40,7 +40,6 @@ import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel -import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.screens.list.VideoCard /** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ @@ -51,13 +50,10 @@ fun rowCardWidth(): androidx.compose.ui.unit.Dp { } /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ -@kotlinx.serialization.Serializable data class ContinueItem(val video: VideoDto, val progress: Float?) class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { - private fun contextOrNull() = AppGraph.appContext - var continueItems by mutableStateOf>(emptyList()) private set var continueLoading by mutableStateOf(true) @@ -68,38 +64,11 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( val recent = RecentVideosViewModel(repository) - init { - // Snapshot restore (issue #9): Home paints from the last session's rows - // immediately; the loads below freshen them from the network. - val context = contextOrNull() - if (context != null) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val savedContinue = Snapshots.loadContinue(context, "home-continue") - val savedSubs = Snapshots.loadVideos(context, "home-subs") - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - if (continueItems.isEmpty() && savedContinue != null) { - continueItems = savedContinue - continueLoading = false - } - if (subItems.isEmpty() && savedSubs != null) { - subItems = savedSubs - } - } - } - } - } - private fun fetchSubs() { viewModelScope.launch { val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() subItems = subs net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) - // Snapshot for the next cold start (issue #9). - contextOrNull()?.let { ctx -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveVideos(ctx, "home-subs", subs) - } - } } } @@ -148,12 +117,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( continueItems = items continueLoading = false net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) - // Snapshot for the next cold start (issue #9). - contextOrNull()?.let { ctx -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveContinue(ctx, "home-continue", items) - } - } } } @@ -168,29 +131,8 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( /** Paged "New videos" row content. */ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { - - // Instant restore from the last session (issue #9); showInstant() refreshes - // the first page in the background. - init { - val context = AppGraph.appContext - if (context != null) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.loadVideos(context, "RecentVideos")?.let { snapshot -> - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - showInstant(snapshot) - } - } - } - } - } - override fun onChunkLoaded(chunk: List) { net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) - AppGraph.appContext?.let { ctx -> - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - Snapshots.saveVideos(ctx, "RecentVideos", items.take(50)) - } - } } override suspend fun fetch(start: Long, count: Int): Result> = diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index 432217c..cd1c618 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -152,8 +152,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi }, ) { Column { - // Persistent top bar: logo/title left, account indicator right. + // Persistent top bar: logo/title left, account button right. TopBar( + onAccountClick = { drawerOpen = !drawerOpen }, profile = userProfile, ) androidx.compose.runtime.key(epoch) { @@ -278,11 +279,13 @@ private fun Destination.icon(): ImageVector = */ @Composable private fun TopBar( + onAccountClick: () -> Unit, profile: PeerTubeRepository.UserProfile?, modifier: Modifier = Modifier, ) { Row( - modifier = Modifier.fillMaxWidth() + modifier = modifier + .fillMaxWidth() .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -294,7 +297,7 @@ private fun TopBar( Box( modifier = Modifier .align(Alignment.Bottom) - .padding(bottom = 16.dp) // sit the logo a touch below the text baseline + .padding(bottom = 17.dp) // slightly below the text baseline .size(32.dp) // match the avatar row so both headers align .clip(RoundedCornerShape(8.dp)) .background(Color(0xFF101010)), @@ -317,10 +320,11 @@ private fun TopBar( style = androidx.compose.material3.MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.weight(1f)) - // Account indicator: avatar + display name. Purely visual — TV focus and - // activation stay in the content/drawer (issue #7's "all the way up" - // surprise must not return); the drawer is reached via the LEFT edge. + // Account indicator: avatar + display name. Purely visual on TV (D-pad + // focus and activation stay in the content/drawer); a click (mouse/touch) + // still opens the menu drawer. Row( + modifier = Modifier.clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { 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 0531d30..b40baf9 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,14 +12,11 @@ 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 @@ -81,13 +78,10 @@ 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 { @@ -279,11 +273,7 @@ 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) @@ -456,18 +446,10 @@ private fun PlayerSurface( .onPreviewKeyEvent { ke -> val code = ke.nativeKeyEvent.keyCode when { - // BACK/Esc: close the sidebar first, else leave the player. + // BACK/Esc leave the player in any state, from any focus. code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { - if (sidebar != null) { - sidebar = null - runCatching { actionBarFocus.requestFocus() } - } else { - onBack() - } - true + 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. @@ -480,7 +462,7 @@ private fun PlayerSurface( ) { consumedDownKey = 0 // trailing UP of a handled press } else { - // UP/DOWN etc. reveal OUR controls, focus the bar. + // UP/DOWN etc. reveal OUR controls, focus the Play button. controlsVisible = true if (surfaceFocused) { runCatching { seekBarFocus.requestFocus() } @@ -515,10 +497,7 @@ private fun PlayerSurface( setOnClickListener { controlsVisible = true } } }, - // 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), + modifier = Modifier.fillMaxSize(), ) // Cover the picture with the loading indicator until the player is really // ready to render — metadata resolution alone (the Loading state) is not @@ -532,8 +511,8 @@ private fun PlayerSurface( contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } - // Controls: SmartTube-style (title + channel above, then the seek bar + - // action row). Persistent when paused, transient while playing. + // Controls: SmartTube-style (title + channel above, then the seek bar). + // Persistent when paused, transient while playing. if (paused || controlsVisible) { PausedControls( detail = s.detail, @@ -541,28 +520,10 @@ 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), - ) - } } } @@ -595,15 +556,9 @@ 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) { @@ -628,6 +583,7 @@ 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) @@ -677,113 +633,29 @@ private fun PausedControls( ) } } - // The seek bar is the only playback control: focused = scrub, OK toggles. + // The seek bar is the only control: focused = scrub, OK = play/pause. 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, - actionRowFocus = actionBarFocus, - modifier = Modifier.padding(top = 12.dp), + modifier = Modifier.padding(top = 16.dp), ) - // 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( + // 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, 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 = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + - formatDuration((durationMs / 1000).toInt()), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, - ) - } + .align(Alignment.End) + .padding(top = 8.dp), + ) } } + /** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ @Composable private fun SeekBarRow( @@ -792,7 +664,6 @@ 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) } @@ -813,14 +684,8 @@ private fun SeekBarRow( KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { seekOverlay(player, positionMs, 10_000L, durationMs); 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_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false + KeyEvent.KEYCODE_DPAD_DOWN -> true KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { @@ -901,57 +766,4 @@ 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