From 9f1c6d54c12adea88c6faf3a2b17eea492f1dddb Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 07:45:17 +0000 Subject: [PATCH 1/3] cache: Show the last session's lists instantly at cold start Opening the app (especially the Channels screen) waited for the API and then for every image before anything painted. JSON list snapshots now live in cacheDir: on the next start the last loaded page paints immediately, then the load that runs anyway silently replaces the first page with fresh data, so stale content can never stick (issue asks exactly for that show-cached-revalidate behavior). Logout wipes the snapshots because they contain account-private listings; History and Search results are never snapshotted. Thumbnails were already disk-cached by Coil (the instance serves them with a one-year max-age). Closes #9 --- .../net/jeena/jtubetv/data/network/Network.kt | 2 +- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 3 + .../jtubetv/ui/list/PagedListViewModel.kt | 24 +++++ .../net/jeena/jtubetv/ui/list/Snapshots.kt | 100 ++++++++++++++++++ .../jtubetv/ui/list/VideoListViewModel.kt | 65 +++++++++++- .../jtubetv/ui/screens/ChannelsScreen.kt | 22 +++- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 58 ++++++++++ 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt 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 dd2b68d..e529cde 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 8bc14bd..7a45d46 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -7,6 +7,7 @@ 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 @@ -83,6 +84,8 @@ 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 3429a76..7a17eb7 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,6 +25,30 @@ 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 new file mode 100644 index 0000000..f2fc26f --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt @@ -0,0 +1,100 @@ +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 dca398c..4535e21 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,6 +10,7 @@ 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 { @@ -41,6 +42,37 @@ 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 { @@ -50,6 +82,20 @@ 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> { @@ -74,7 +120,24 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { - viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() } + // 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) + } + } + } + } } } 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 a97a119..d6f0d2d 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,6 +59,7 @@ 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 @@ -78,6 +79,7 @@ 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, @@ -87,6 +89,10 @@ 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()) @@ -115,9 +121,17 @@ 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 + } + } + fun load(force: Boolean = false) { if (!force && userRows.isNotEmpty()) return @@ -241,6 +255,12 @@ 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 74cf99a..3def3a1 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,6 +40,7 @@ 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. */ @@ -50,10 +51,13 @@ 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) @@ -64,11 +68,38 @@ 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) + } + } } } @@ -117,6 +148,12 @@ 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) + } + } } } @@ -131,8 +168,29 @@ 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> = From 9ca65c121b94d589599ca6713da6ab524d7a83d4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 07:45:17 +0000 Subject: [PATCH 2/3] player: Action row with subscribe, rating, comments and playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 70 +++ .../java/net/jeena/jtubetv/data/api/Dtos.kt | 66 ++- .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 55 +++ .../jeena/jtubetv/ui/player/PlayerSidebar.kt | 412 ++++++++++++++++++ .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 232 +++++++++- 5 files changed, 812 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 496ca1f..3138989 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -3,15 +3,22 @@ package net.jeena.jtubetv.data import kotlinx.serialization.json.Json import net.jeena.jtubetv.data.api.HistoryElement import net.jeena.jtubetv.data.api.ChannelStats +import net.jeena.jtubetv.data.api.CommentThreadsResponse +import net.jeena.jtubetv.data.api.AddToPlaylistBody +import net.jeena.jtubetv.data.api.CreatePlaylistBody import net.jeena.jtubetv.data.api.OAuthClient +import net.jeena.jtubetv.data.api.PlaylistCreatedResponse +import net.jeena.jtubetv.data.api.RatingBody import net.jeena.jtubetv.data.api.ServerConfigDto import net.jeena.jtubetv.data.api.PeerTubeApi +import net.jeena.jtubetv.data.api.SubscribeBody import net.jeena.jtubetv.data.api.TokenResponse import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelListResponse import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.api.VideoListResponse +import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto import net.jeena.jtubetv.data.api.WatchingBody import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.network.PeerTubeNetwork @@ -19,6 +26,7 @@ import net.jeena.jtubetv.data.network.apiUrlOf import net.jeena.jtubetv.data.network.normalizeBaseUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit @@ -233,6 +241,68 @@ class PeerTubeRepository( fun hasSession(): Boolean = store.hasSession() + // ---- interactions (issue #10) ---------------------------------------- + + /** True if the user follows `channel@host`, false if not subscribed. */ + suspend fun subscriptionExists(handle: String): Result = runCatching { + network.api.subscriptionsExist(listOf(handle))[handle] == true + } + + suspend fun subscribe(handle: String): Result = runCatching { + val r = network.api.subscribe(SubscribeBody(handle)) + require(r.isSuccessful) { "subscribe failed: " + r.code() } + }.map { } + + /** 404 (never subscribed) counts as unsubscribed. */ + suspend fun unsubscribe(handle: String): Result = runCatching { + network.api.unsubscribe(handle) + }.recoverCatching { e -> + if (e is retrofit2.HttpException && e.code() == 404) Unit else throw e + } + + /** "none" | "like" | "dislike" */ + suspend fun myRating(shortUuid: String): Result = + runCatching { network.api.myRating(shortUuid).rating } + + suspend fun rate(shortUuid: String, rating: String): Result = + runCatching { network.api.rate(shortUuid, RatingBody(rating)) } + + /** Top-level comment threads, newest first-ish (server default). */ + suspend fun comments(shortUuid: String, start: Long, count: Int): Result = + runCatching { network.api.commentThreads(shortUuid, start, count) } + + /** Playlists owned by the logged-in user (their account handle). */ + suspend fun myPlaylists(): Result> = runCatching { + val account = store.accountName ?: throw IllegalStateException("No account") + val first = network.api.accountPlaylists(account, 0, 100) + val rest = if (first.total > first.data.size) { + network.api.accountPlaylists(account, first.data.size.toLong(), (first.total - first.data.size).toInt()) + .data + } else emptyList() + first.data + rest + } + + /** Playlist id → element id for the playlists containing the video (numeric video id). */ + suspend fun playlistsContaining(videoId: Long): Result> = runCatching { + network.api.playlistsContaining(videoId) + .values + .flatten() + .mapNotNull { element -> element.playlistId?.let { element.elementId?.let { e -> it to e } } } + .toMap() + } + + suspend fun addToPlaylist(playlistId: Long, videoId: Long): Result = + runCatching { network.api.addToPlaylist(playlistId, AddToPlaylistBody(videoId)) } + + suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result = + runCatching { network.api.removeFromPlaylist(playlistId, elementId) } + + suspend fun createPlaylist(displayName: String): Result = runCatching { + val created = network.api.createPlaylist(CreatePlaylistBody(displayName)) + .videoPlaylist ?: throw IllegalStateException("No playlist in response") + VideoPlaylistSummaryDto(id = created.id, displayName = displayName) + } + /** Full logout (feature request from UX review): forget URL, username and tokens. */ fun signOut() = store.clearAll() diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 4507861..86d8916 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -119,6 +119,8 @@ data class VideoDetailDto( val thumbnailPath: String? = null, val privacy: Labelled? = null, val views: Long = 0, + val likes: Long = 0, + val dislikes: Long = 0, val nsfw: Boolean = false, val channel: VideoChannelSummary, val files: List = emptyList(), @@ -176,4 +178,66 @@ data class UserDto( data class UserAccountDto( @SerialName("displayName") val displayName: String? = null, val avatars: List = emptyList(), -) \ No newline at end of file +) + +// ---- interactions under the video (issue #10) ---------------------------- + +/** One thread comment; `account` reuses [ChannelOwnerAccount] (name + displayName + avatars). */ +@Serializable +data class CommentThreadDto( + val threadId: Long = 0, + val text: String = "", + val createdAt: String? = null, + @SerialName("totalReplies") val totalReplies: Int = 0, + val account: ChannelOwnerAccount? = null, +) + +@Serializable +data class CommentThreadsResponse( + val total: Long = 0, + val data: List = emptyList(), +) + +@Serializable +data class VideoPlaylistSummaryDto( + val id: Long, + @SerialName("displayName") val displayName: String, + val privacy: Labelled? = null, +) + +@Serializable +data class VideoPlaylistListResponse( + val total: Long = 0, + val data: List = emptyList(), +) + +/** Membership hit within one playlist (from videos-exist). */ +@Serializable +data class PlaylistElementDto( + val playlistId: Long? = null, + @SerialName("playlistElementId") val elementId: Long? = null, +) + +@Serializable +data class RateResponse( + val rating: String = "none", +) + +// request bodies +@Serializable +data class SubscribeBody(val uri: String) + +@Serializable +data class RatingBody(val rating: String) + +@Serializable +data class AddToPlaylistBody(val videoId: Long) + +@Serializable +data class CreatePlaylistBody(val displayName: String) + +@Serializable +data class PlaylistCreatedDto(val id: Long = 0) + +@Serializable +data class PlaylistCreatedResponse(val videoPlaylist: PlaylistCreatedDto? = null) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 1c5940a..2ef6672 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -1,10 +1,13 @@ package net.jeena.jtubetv.data.api +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.DELETE +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -120,4 +123,56 @@ interface PeerTubeApi { @GET("config") suspend fun serverConfig(): ServerConfigDto + + // ---- interactions (issue #10) ---------------------------------------- + + @GET("videos/{uuid}/comment-threads") + suspend fun commentThreads( + @Path("uuid") uuid: String, + @Query("start") start: Long, + @Query("count") count: Int, + ): CommentThreadsResponse + + @GET("accounts/{account}/video-playlists") + suspend fun accountPlaylists( + @Path("account") account: String, + @Query("start") start: Long, + @Query("count") count: Int, + @Query("sort") sort: String = "-createdAt", + ): VideoPlaylistListResponse + + @POST("video-playlists") + suspend fun createPlaylist(@Body body: CreatePlaylistBody): PlaylistCreatedResponse + + @POST("video-playlists/{playlistId}/videos") + suspend fun addToPlaylist( + @Path("playlistId") playlistId: Long, + @Body body: AddToPlaylistBody, + ) + + @DELETE("video-playlists/{playlistId}/videos/{elementId}") + suspend fun removeFromPlaylist( + @Path("playlistId") playlistId: Long, + @Path("elementId") elementId: Long, + ) + + @GET("users/me/video-playlists/videos-exist") + suspend fun playlistsContaining( + @Query("videoIds") videoId: Long, + ): Map> + + @POST("users/me/subscriptions") + suspend fun subscribe(@Body body: SubscribeBody): Response + + @DELETE("users/me/subscriptions/{handle}") + suspend fun unsubscribe(@Path("handle") handle: String) + + @GET("users/me/subscriptions/exist") + suspend fun subscriptionsExist(@Query("uris[]") uris: List): Map + + @GET("users/me/videos/{uuid}/rating") + suspend fun myRating(@Path("uuid") uuid: String): RateResponse + + @PUT("videos/{uuid}/rate") + suspend fun rate(@Path("uuid") uuid: String, @Body body: RatingBody) } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt new file mode 100644 index 0000000..43bc01c --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -0,0 +1,412 @@ +package net.jeena.jtubetv.ui.player + +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Button +import coil.compose.AsyncImage +import kotlinx.coroutines.launch +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable +import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto +import net.jeena.jtubetv.data.api.CommentThreadDto +import net.jeena.jtubetv.data.api.VideoDetailDto + +/** + * Right sidebar for the player (issue #10): "comments" or "playlists" panel. + * While open, the surface shrinks the video with `end` padding of + * [SIDEBAR_WIDTH]. Rows are D-pad navigable; BACK closes (handled by the + * surface) and LEFT over any row closes too. + */ +val SIDEBAR_WIDTH = 420.dp + +private val SidebarBg = Color(0xFF0E0E0E) +private val TextMain = Color(0xFFE0E0E0) +private val TextDim = Color(0xFF9A9A9A) +private val Accent = Color(0xFFCE2B2B) + +@Composable +fun PlayerSidebar( + mode: String, // "comments" | "playlists" + detail: VideoDetailDto, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxHeight() + .width(SIDEBAR_WIDTH) + .background(SidebarBg), + ) { + Text( + text = if (mode == "playlists") "Playlists" else "Comments", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(20.dp), + ) + if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss) + } +} + +/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */ +@Composable +private fun SidebarFallback(message: String, onDismiss: () -> Unit) { + val fr = remember { FocusRequester() } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(120) + runCatching { fr.requestFocus() } + } + Text( + text = message, + color = TextDim, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + .onPreviewKeyEvent { ke -> + if (ke.type == KeyEventType.KeyDown && + ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT + ) { + onDismiss() + true + } else false + } + .focusRequester(fr) + .focusable(), + ) +} + +/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */ +@Composable +private fun SidebarRow( + modifier: Modifier = Modifier, + onLeft: (() -> Unit)? = null, + focusRequester: FocusRequester? = null, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + var m = modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color(0x1AFFFFFF) else Color.Transparent) + if (focusRequester != null) m = m.focusRequester(focusRequester) + if (onLeft != null) { + m = m.onPreviewKeyEvent { ke -> + if (ke.type == KeyEventType.KeyDown && + ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT + ) { + onLeft() + true + } else false + } + } + m = m + .dpadCenterActivatable(onClick ?: {}) + .focusable() + if (onClick != null) m = m.clickable(onClick = onClick) + Row( + modifier = m.padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { content() } +} + +// ---- comments -------------------------------------------------------------- + +@Composable +private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { + val repository = AppGraph.repository + // Comments load once per opening; paging via list end detection. + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableStateOf(0L) } + var start by remember { mutableStateOf(0L) } + var loading by remember { mutableStateOf(true) } + var failed by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val firstRow = remember { FocusRequester() } + + suspend fun fetchNext() { + loading = true + repository.comments(detail.shortUUID, start, 20).fold( + onSuccess = { r -> + items = items + r.data + start = items.size.toLong() + total = r.total + }, + onFailure = { + // Only fail the FIRST page; later page errors keep what we have. + if (items.isEmpty()) failed = true + }, + ) + loading = false + } + + LaunchedEffect(Unit) { fetchNext() } + + val lastVisibleIndex by remember { + androidx.compose.runtime.derivedStateOf { + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + } + } + LaunchedEffect(lastVisibleIndex, total) { + if (failed || loading || total <= 0) return@LaunchedEffect + val last = lastVisibleIndex ?: return@LaunchedEffect + if (start < total && last >= items.size - 2) fetchNext() + } + + // D-pad focus: land on the first comment when ready. + LaunchedEffect(loading, failed, items.size) { + if (!loading && !failed && items.isNotEmpty()) { + kotlinx.coroutines.delay(150) + runCatching { firstRow.requestFocus() } + } + } + + Box(Modifier.fillMaxSize()) { + when { + loading && items.isEmpty() -> SidebarFallback("Loading…", onDismiss) + failed -> SidebarFallback("Comments are not available here.", onDismiss) + items.isEmpty() -> SidebarFallback("No comments yet.", onDismiss) + else -> + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items.forEachIndexed { index, comment -> + item(key = "c" + comment.threadId) { + CommentRow( + comment = comment, + onLeft = onDismiss, + focusRequester = if (index == 0) firstRow else null, + ) + } + } + if (start < total) { + item(key = "more") { + Text( + "Loading more…", + color = TextDim, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(20.dp), + ) + } + } + } + } + } +} + +@Composable +private fun CommentRow( + comment: CommentThreadDto, + onLeft: () -> Unit, + focusRequester: FocusRequester? = null, +) { + val avatar = comment.account?.avatars?.maxByOrNull { it.width } + SidebarRow( + modifier = Modifier.padding(vertical = 4.dp), + onLeft = onLeft, + focusRequester = focusRequester, + ) { + if (avatar != null) { + AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(36.dp).clip(CircleShape), + ) + } + Column(Modifier.padding(start = 10.dp)) { + Text( + text = (comment.account?.displayName ?: "?") + + " " + formatDateShort(comment.createdAt), + color = TextDim, + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = comment.text, + color = TextMain, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 2.dp), + ) + if (comment.totalReplies > 0) { + Text( + text = comment.totalReplies.toString() + " replies", + color = TextDim, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + +// ---- playlists --------------------------------------------------------------- + +@Composable +private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) { + val repository = AppGraph.repository + val scope = rememberCoroutineScope() + var playlists by remember { mutableStateOf>(emptyList()) } + var containedBy by remember { mutableStateOf>(emptyMap()) } + var loading by remember { mutableStateOf(true) } + var failed by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + val inputFocus = remember { FocusRequester() } + val rowsFocus = remember { FocusRequester() } + + suspend fun refreshHits() { + repository.playlistsContaining(detail.id).fold( + onSuccess = { containedBy = it }, + onFailure = { }, + ) + } + + LaunchedEffect(detail.id) { + repository.myPlaylists().fold( + onSuccess = { playlists = it }, + onFailure = { failed = true }, + ) + refreshHits() + loading = false + } + + fun toggle(playlist: VideoPlaylistSummaryDto) { + val elementId = containedBy[playlist.id] + scope.launch { + if (elementId != null) { + repository.removeFromPlaylist(playlist.id, elementId) + } else { + repository.addToPlaylist(playlist.id, detail.id) + } + // Membership checks settle server-side; re-check after a moment. + kotlinx.coroutines.delay(1000) + refreshHits() + } + } + + fun create() { + val name = newName.trim() + if (name.isEmpty()) return + scope.launch { + repository.createPlaylist(name).onSuccess { playlist -> + if (playlist != null) { + playlists = listOf(playlist) + playlists + newName = "" + } + } + } + } + + LaunchedEffect(loading) { + if (!loading && !failed && playlists.isNotEmpty()) { + kotlinx.coroutines.delay(150) + runCatching { rowsFocus.requestFocus() } + } + } + + Column(Modifier.fillMaxSize()) { + // New playlist: text field + create button. + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = newName, + onValueChange = { newName = it }, + singleLine = true, + label = { Text("New playlist") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier + .weight(1f) + .focusRequester(inputFocus) + .padding(start = 16.dp), + ) + Button( + onClick = { create() }, + modifier = Modifier.padding(start = 10.dp, end = 16.dp), + ) { Text("Create") } + } + when { + loading -> SidebarFallback("Loading…", onDismiss) + failed -> SidebarFallback("Could not load playlists", onDismiss) + playlists.isEmpty() -> SidebarFallback("No playlists yet.", onDismiss) + else -> + LazyColumn(Modifier.fillMaxSize()) { + playlists.forEachIndexed { index, playlist -> + val checked = containedBy.containsKey(playlist.id) + item(key = "p" + playlist.id) { + SidebarRow( + onLeft = onDismiss, + onClick = { toggle(playlist) }, + focusRequester = if (index == 0) rowsFocus else null, + ) { + PlaylistCheckbox(checked) + Text( + text = playlist.displayName, + color = TextMain, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + maxLines = 1, + ) + } + } + } + } + } + } +} + +@Composable +private fun PlaylistCheckbox(checked: Boolean) { + Box( + modifier = Modifier + .size(22.dp) + .clip(RoundedCornerShape(5.dp)) + .background(if (checked) Accent else Color(0x33FFFFFF)), + ) +} + +private fun formatDateShort(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return runCatching { + java.time.ZonedDateTime.ofInstant( + java.time.Instant.parse(iso), + java.time.ZoneId.systemDefault(), + ).format(java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy", java.util.Locale.getDefault())) + }.getOrElse { "" } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index b40baf9..0531d30 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -12,11 +12,14 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -78,10 +81,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable import net.jeena.jtubetv.R import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.auth.TokenStore +import net.jeena.jtubetv.ui.player.PlayerSidebar +import net.jeena.jtubetv.ui.player.SIDEBAR_WIDTH import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable sealed interface PlayerUiState { @@ -273,7 +279,11 @@ private fun PlayerSurface( var consumedDownKey by remember { mutableStateOf(0) } // Overlay focus/position state for the controls (#4). val seekBarFocus = remember { FocusRequester() } + // First button of the action row (focus target when the sidebar closes). + val actionBarFocus = remember { FocusRequester() } val positionMs = remember { androidx.compose.runtime.mutableLongStateOf(0L) } + // Right sidebar (issue #10): "comments" | "playlists"; null = closed. + var sidebar by remember { mutableStateOf(null) } val player = remember { val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) @@ -446,10 +456,18 @@ private fun PlayerSurface( .onPreviewKeyEvent { ke -> val code = ke.nativeKeyEvent.keyCode when { - // BACK/Esc leave the player in any state, from any focus. + // BACK/Esc: close the sidebar first, else leave the player. code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> { - onBack(); true + if (sidebar != null) { + sidebar = null + runCatching { actionBarFocus.requestFocus() } + } else { + onBack() + } + true } + // Sidebar open: our rows navigate (they eat LEFT too). + sidebar != null -> false // While paused our overlay has focus and handles everything. paused -> false // Transient controls hold focus while playing: let them move it. @@ -462,7 +480,7 @@ private fun PlayerSurface( ) { consumedDownKey = 0 // trailing UP of a handled press } else { - // UP/DOWN etc. reveal OUR controls, focus the Play button. + // UP/DOWN etc. reveal OUR controls, focus the bar. controlsVisible = true if (surfaceFocused) { runCatching { seekBarFocus.requestFocus() } @@ -497,7 +515,10 @@ private fun PlayerSurface( setOnClickListener { controlsVisible = true } } }, - modifier = Modifier.fillMaxSize(), + // With the sidebar open the video shrinks to the left (issue #10). + modifier = Modifier + .fillMaxSize() + .padding(end = if (sidebar != null) SIDEBAR_WIDTH else 0.dp), ) // Cover the picture with the loading indicator until the player is really // ready to render — metadata resolution alone (the Loading state) is not @@ -511,8 +532,8 @@ private fun PlayerSurface( contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } - // Controls: SmartTube-style (title + channel above, then the seek bar). - // Persistent when paused, transient while playing. + // Controls: SmartTube-style (title + channel above, then the seek bar + + // action row). Persistent when paused, transient while playing. if (paused || controlsVisible) { PausedControls( detail = s.detail, @@ -520,10 +541,28 @@ private fun PlayerSurface( positionMs = positionMs, playing = !paused, seekBarFocus = seekBarFocus, + actionBarFocus = actionBarFocus, onOpenChannel = onOpenChannel, + openSidebar = { mode -> + sidebar = mode + }, modifier = Modifier.align(Alignment.BottomCenter), ) } + // Right sidebar (comments / playlists); the video is shrunk to its left. + sidebar?.let { mode -> + PlayerSidebar( + mode = mode, + detail = s.detail, + onDismiss = { + sidebar = null + runCatching { actionBarFocus.requestFocus() } + }, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 48.dp), + ) + } } } @@ -556,9 +595,15 @@ private fun PausedControls( seekBarFocus: FocusRequester, // (handle, displayName) — opens the channel's videos page; exits the player. onOpenChannel: (String, String) -> Unit = { _, _ -> }, + // First action-row button focus (used by the surface when a sidebar closes). + actionBarFocus: FocusRequester? = null, + // "comments" | "playlists" — opens the right sidebar (issue #10). + openSidebar: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val durationMs = detail.duration * 1000L + val scope = androidx.compose.runtime.rememberCoroutineScope() + val channelFocus = remember { FocusRequester() } // While playing, the position text must keep counting. LaunchedEffect(playing) { while (playing) { @@ -583,7 +628,6 @@ private fun PausedControls( // Channel icon: a button — opens the channel's videos page (its avatar is // optional: federated channels may have none). var channelFocused by remember { mutableStateOf(false) } - val channelFocus = remember { FocusRequester() } var channelModifier: Modifier = Modifier.size(48.dp) channelModifier = channelModifier .focusRequester(channelFocus) @@ -633,29 +677,113 @@ private fun PausedControls( ) } } - // The seek bar is the only control: focused = scrub, OK = play/pause. + // The seek bar is the only playback control: focused = scrub, OK toggles. SeekBarRow( positionMs = positionMs, durationMs = durationMs, seekBarFocus = seekBarFocus, player = player, - // UP climbs to the channel icon when it exists. channelFocus = if (detail.channel.avatars.isNotEmpty()) channelFocus else null, - modifier = Modifier.padding(top = 16.dp), + actionRowFocus = actionBarFocus, + modifier = Modifier.padding(top = 12.dp), ) - // Position / duration under the bar, right-aligned. - Text( - text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + - formatDuration((durationMs / 1000).toInt()), - color = Color(0xFFCCCCCC), - style = MaterialTheme.typography.bodyMedium, + // Action row under the red line (issue #10): subscribe, like/dislike, + // comments + playlists open the right sidebar; times on the right. + val repository = AppGraph.repository + val scope = androidx.compose.runtime.rememberCoroutineScope() + var subscribed by remember { mutableStateOf(null) } + var rating by remember { mutableStateOf("none") } + // Counts update optimistically alongside the rating (the DTO is static). + var likeCount by remember { mutableStateOf(detail.likes) } + var dislikeCount by remember { mutableStateOf(detail.dislikes) } + val channelHandle = detail.channel.name + "@" + (detail.channel.host ?: "") + LaunchedEffect(detail.shortUUID) { + repository.subscriptionExists(channelHandle).onSuccess { subscribed = it } + repository.myRating(detail.shortUUID).onSuccess { rating = it } + } + Row( modifier = Modifier - .align(Alignment.End) - .padding(top = 8.dp), - ) + .fillMaxWidth() + .padding(top = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerActionButton( + label = when (subscribed) { + true -> "Subscribed" + false -> "Subscribe" + else -> "Subscribe" + }, + filled = subscribed == true, + focusRequester = actionBarFocus, + ) { done -> + scope.launch { + val wasSubscribed = subscribed == true + val ok = if (wasSubscribed) { + repository.unsubscribe(channelHandle).isSuccess + } else { + repository.subscribe(channelHandle).isSuccess + } + if (ok) { + // Optimistic: the server's exist-check is eventually + // consistent right after a POST — trust our action, then + // re-verify a moment later. + subscribed = !wasSubscribed + kotlinx.coroutines.delay(1500) + repository.subscriptionExists(channelHandle).onSuccess { subscribed = it } + } + done() + } + } + PlayerActionButton( + label = "👍 " + likeCount, + filled = rating == "like", + ) { done -> + scope.launch { + val hadLiked = rating == "like" + val target = if (hadLiked) "none" else "like" + if (repository.rate(detail.shortUUID, target).isSuccess) { + rating = target + likeCount = if (hadLiked) likeCount - 1 else likeCount + 1 + } + done() + } + } + PlayerActionButton( + label = "👎 " + dislikeCount, + filled = rating == "dislike", + ) { done -> + scope.launch { + val hadDisliked = rating == "dislike" + val target = if (hadDisliked) "none" else "dislike" + if (repository.rate(detail.shortUUID, target).isSuccess) { + rating = target + dislikeCount = if (hadDisliked) dislikeCount - 1 else dislikeCount + 1 + } + done() + } + } + PlayerActionButton( + label = "Comments", + ) { done -> + done() + openSidebar("comments") + } + PlayerActionButton( + label = "Playlists", + ) { done -> + done() + openSidebar("playlists") + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " + + formatDuration((durationMs / 1000).toInt()), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + } } } - /** Focusable + seekable progress bar: arrows scrub ±10 s, OK toggles playback. */ @Composable private fun SeekBarRow( @@ -664,6 +792,7 @@ private fun SeekBarRow( seekBarFocus: FocusRequester, player: ExoPlayer, channelFocus: FocusRequester? = null, // UP target: the channel icon (or stay) + actionRowFocus: FocusRequester? = null, // DOWN target: the action row modifier: Modifier = Modifier, ) { var barFocused by remember { mutableStateOf(false) } @@ -684,8 +813,14 @@ private fun SeekBarRow( KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { seekOverlay(player, positionMs, 10_000L, durationMs); true } - KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false - KeyEvent.KEYCODE_DPAD_DOWN -> true + KeyEvent.KEYCODE_DPAD_UP -> { + if (channelFocus != null) runCatching { channelFocus.requestFocus() } + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + if (actionRowFocus != null) runCatching { actionRowFocus.requestFocus() } + true + } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY -> { @@ -766,4 +901,57 @@ private fun formatDuration(seconds: Int): String { h > 0 -> "%d:%02d:%02d".format(h, m, s) else -> "%d:%02d".format(m, s) } +} + +/** Pill button of the video action row (issue #10): text or emoji-labelled; the + * subscribed/liked states get a solid accent background. */ +@Composable +private fun PlayerActionButton( + label: String, + filled: Boolean = false, + focusRequester: FocusRequester? = null, + // [onClick] receives an onDone callback; only call it when the action's + // network roundtrip finished — events arriving before onDone are dropped. + onClick: (onDone: () -> Unit) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + // One activation per press: on some devices CENTER also triggers the platform + // click; without a guard the double event would subscribe and immediately + // unsubscribe (observed live). The guard also blocks spammage while a request + // is in flight. + var inFlight by remember { mutableStateOf(false) } + fun activate() { + if (inFlight) return + inFlight = true + onClick { inFlight = false } + } + + var m: Modifier = Modifier + if (focusRequester != null) m = m.focusRequester(focusRequester) + m = m + .onFocusChanged { focused = it.isFocused } + .dpadCenterActivatable({ activate() }) + .focusable() + .background( + when { + focused -> Color(0x55FFFFFF) + filled -> Color(0x33CE2B2B) + else -> Color(0x22FFFFFF) + }, + RoundedCornerShape(20.dp), + ) + .then( + if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp)) + else Modifier + ) + // Click only for real pointer input; the key activation above is guarded. + .pointerInput(Unit) { detectTapGestures { activate() } } + Box(modifier = m.padding(horizontal = 14.dp, vertical = 8.dp)) { + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } } \ No newline at end of file From 4af904bf2ac909281329ddeab790800fcaa0981b Mon Sep 17 00:00:00 2001 From: Jeena Date: Tue, 13 Oct 2026 07:45:17 +0000 Subject: [PATCH 3/3] home: Nudge the logo below the text baseline On one baseline the glyph looked like it was sinking into the title; 1dp lower matches how the launcher icon sits next to text. Closes #11 --- .../net/jeena/jtubetv/ui/screens/HomeScreen.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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 cd1c618..432217c 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,9 +152,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi }, ) { Column { - // Persistent top bar: logo/title left, account button right. + // Persistent top bar: logo/title left, account indicator right. TopBar( - onAccountClick = { drawerOpen = !drawerOpen }, profile = userProfile, ) androidx.compose.runtime.key(epoch) { @@ -279,13 +278,11 @@ 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, ) { @@ -297,7 +294,7 @@ private fun TopBar( Box( modifier = Modifier .align(Alignment.Bottom) - .padding(bottom = 17.dp) // slightly below the text baseline + .padding(bottom = 16.dp) // sit the logo a touch below the text baseline .size(32.dp) // match the avatar row so both headers align .clip(RoundedCornerShape(8.dp)) .background(Color(0xFF101010)), @@ -320,11 +317,10 @@ private fun TopBar( style = androidx.compose.material3.MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.weight(1f)) - // 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. + // 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. Row( - modifier = Modifier.clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) {