Compare commits

...

3 commits

Author SHA1 Message Date
4af904bf2a 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
2026-10-13 07:45:17 +00:00
9ca65c121b player: Action row with subscribe, rating, comments and playlists
SmartTube keeps its action buttons under the seek bar — we had nothing.
The paused/controls overlay gained: subscribe (POST /users/me/
subscriptions with the channel URI, state via the bulk exist endpoint),
like/dislike with immediate count and fill feedback (PUT /videos/{uuid}/
rate), and right-sidebar panels for comments (GET /videos/{uuid}/
comment-threads, paged, read-only; the instance has comments disabled on
some videos, which surfaces as a friendly empty state) and playlists
(checkboxes add/remove the video via the playlist-elements endpoints, a
text field creates new ones). Opening a panel shrinks the video so it
stays fully visible on the left; BACK or LEFT closes again.

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

Closes #10
2026-10-13 07:45:17 +00:00
9f1c6d54c1 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
2026-10-13 07:45:17 +00:00
13 changed files with 1089 additions and 36 deletions

View file

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

View file

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

View file

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

View file

@ -161,4 +161,4 @@ class TokenAuthenticator(
} }
return count return count
} }
} }

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.ui.list.Snapshots
sealed interface SessionState { sealed interface SessionState {
data object Loading : 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. */ /** Clears the stored session; the user returns to the setup screen. */
fun logout() { fun logout() {
repository.signOut() repository.signOut()
// Snapshots contain account-private listings — invalidate them.
AppGraph.appContext?.let { Snapshots.clearAll(it) }
_userProfile.value = null _userProfile.value = null
_sessionState.value = SessionState.NeedsSetup _sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1 sessionEpoch.value += 1

View file

@ -25,6 +25,30 @@ abstract class PagedListViewModel<T> : ViewModel() {
var loadingInitial by mutableStateOf(true) var loadingInitial by mutableStateOf(true)
private set 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<T>) {
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<List<T>> protected abstract suspend fun fetch(start: Long, count: Int): Result<List<T>>
/** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */

View file

@ -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<VideoDto>) = runCatching {
write(context, key, json.encodeToString(ListSerializer(VideoDto.serializer()), videos))
}
fun loadVideos(context: Context, key: String): List<VideoDto>? =
read(context, key)?.let { raw ->
runCatching { json.decodeFromString(ListSerializer(VideoDto.serializer()), raw) }.getOrNull()
}
fun saveUserGroups(
context: Context,
key: String,
rows: List<net.jeena.jtubetv.ui.screens.UserGroup>,
) = runCatching {
write(
context,
key,
json.encodeToString(
ListSerializer(net.jeena.jtubetv.ui.screens.UserGroup.serializer()),
rows,
),
)
}
fun loadUserGroups(context: Context, key: String): List<net.jeena.jtubetv.ui.screens.UserGroup>? =
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<net.jeena.jtubetv.ui.screens.ContinueItem>) = runCatching {
write(
context,
key,
json.encodeToString(
ListSerializer(net.jeena.jtubetv.ui.screens.ContinueItem.serializer()),
items,
),
)
}
fun loadContinue(context: Context, key: String): List<net.jeena.jtubetv.ui.screens.ContinueItem>? =
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()
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.ui.list.Snapshots
/** Where the list's content comes from. */ /** Where the list's content comes from. */
sealed interface ListSource { sealed interface ListSource {
@ -41,6 +42,37 @@ class VideoListViewModel(
) )
private set 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<VideoListItem>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails( ImagePrefetcher.videoThumbnails(
chunk.mapNotNull { 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<VideoListItem.Video>().take(50).map { it.video },
)
}
}
}
} }
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> { override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
@ -74,7 +120,24 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) { fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source val s = source
if (s is ListSource.Channel) { 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)
}
}
}
}
} }
} }

View file

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

View file

@ -59,6 +59,7 @@ import net.jeena.jtubetv.data.api.Avatar
import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg 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 * the full listing (max 100/page) and fetch per-channel stats (videos + sum of
* video views = plays) with small throttled calls. * video views = plays) with small throttled calls.
*/ */
@kotlinx.serialization.Serializable
data class UserGroup( data class UserGroup(
val ownerName: String, val ownerName: String,
val label: String, val label: String,
@ -87,6 +89,10 @@ data class UserGroup(
class ChannelsViewModel(private val store: TokenStore) : ViewModel() { 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<UserDto?>(null) var user by mutableStateOf<UserDto?>(null)
private set private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList()) var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
@ -115,9 +121,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private set private set
private val repo: PeerTubeRepository = AppGraph.repository private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>() private val statsMap = HashMap<String, ChannelStats>()
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) { fun load(force: Boolean = false) {
if (!force && userRows.isNotEmpty()) return 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 { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { it.followersCount } }, .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( Log.w(
"JTube", "JTube",
"order now: " + userRows.take(6).joinToString(" | ") { g -> "order now: " + userRows.take(6).joinToString(" | ") { g ->

View file

@ -40,6 +40,7 @@ import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.screens.list.VideoCard import net.jeena.jtubetv.ui.screens.list.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ /** 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). */ /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */
@kotlinx.serialization.Serializable
data class ContinueItem(val video: VideoDto, val progress: Float?) data class ContinueItem(val video: VideoDto, val progress: Float?)
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
private fun contextOrNull() = AppGraph.appContext
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList()) var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
private set private set
var continueLoading by mutableStateOf(true) var continueLoading by mutableStateOf(true)
@ -64,11 +68,38 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
val recent = RecentVideosViewModel(repository) 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() { private fun fetchSubs() {
viewModelScope.launch { viewModelScope.launch {
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subItems = subs subItems = subs
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(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 continueItems = items
continueLoading = false continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) 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. */ /** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() { class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
// 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<VideoDto>) { override fun onChunkLoaded(chunk: List<VideoDto>) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) 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<List<VideoDto>> = override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =

View file

@ -152,9 +152,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
}, },
) { ) {
Column { Column {
// Persistent top bar: logo/title left, account button right. // Persistent top bar: logo/title left, account indicator right.
TopBar( TopBar(
onAccountClick = { drawerOpen = !drawerOpen },
profile = userProfile, profile = userProfile,
) )
androidx.compose.runtime.key(epoch) { androidx.compose.runtime.key(epoch) {
@ -279,13 +278,11 @@ private fun Destination.icon(): ImageVector =
*/ */
@Composable @Composable
private fun TopBar( private fun TopBar(
onAccountClick: () -> Unit,
profile: PeerTubeRepository.UserProfile?, profile: PeerTubeRepository.UserProfile?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), .padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -297,7 +294,7 @@ private fun TopBar(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.Bottom) .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 .size(32.dp) // match the avatar row so both headers align
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xFF101010)), .background(Color(0xFF101010)),
@ -320,11 +317,10 @@ private fun TopBar(
style = androidx.compose.material3.MaterialTheme.typography.titleLarge, style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Account indicator: avatar + display name. Purely visual on TV (D-pad // Account indicator: avatar + display name. Purely visual — TV focus and
// focus and activation stay in the content/drawer); a click (mouse/touch) // activation stay in the content/drawer (issue #7's "all the way up"
// still opens the menu drawer. // surprise must not return); the drawer is reached via the LEFT edge.
Row( Row(
modifier = Modifier.clickable(onClick = onAccountClick),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) { ) {

View file

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