Compare commits
3 commits
fdc570ac9e
...
4af904bf2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4af904bf2a | |||
| 9ca65c121b | |||
| 9f1c6d54c1 |
13 changed files with 1089 additions and 36 deletions
|
|
@ -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<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. */
|
||||
fun signOut() = store.clearAll()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<VideoFile> = emptyList(),
|
||||
|
|
@ -177,3 +179,65 @@ data class UserAccountDto(
|
|||
@SerialName("displayName") val displayName: String? = null,
|
||||
val avatars: List<Avatar> = emptyList(),
|
||||
)
|
||||
|
||||
// ---- interactions under the video (issue #10) ----------------------------
|
||||
|
||||
/** One thread comment; `account` reuses [ChannelOwnerAccount] (name + displayName + avatars). */
|
||||
@Serializable
|
||||
data class CommentThreadDto(
|
||||
val threadId: Long = 0,
|
||||
val text: String = "",
|
||||
val createdAt: String? = null,
|
||||
@SerialName("totalReplies") val totalReplies: Int = 0,
|
||||
val account: ChannelOwnerAccount? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CommentThreadsResponse(
|
||||
val total: Long = 0,
|
||||
val data: List<CommentThreadDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoPlaylistSummaryDto(
|
||||
val id: Long,
|
||||
@SerialName("displayName") val displayName: String,
|
||||
val privacy: Labelled? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoPlaylistListResponse(
|
||||
val total: Long = 0,
|
||||
val data: List<VideoPlaylistSummaryDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** Membership hit within one playlist (from videos-exist). */
|
||||
@Serializable
|
||||
data class PlaylistElementDto(
|
||||
val playlistId: Long? = null,
|
||||
@SerialName("playlistElementId") val elementId: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RateResponse(
|
||||
val rating: String = "none",
|
||||
)
|
||||
|
||||
// request bodies
|
||||
@Serializable
|
||||
data class SubscribeBody(val uri: String)
|
||||
|
||||
@Serializable
|
||||
data class RatingBody(val rating: String)
|
||||
|
||||
@Serializable
|
||||
data class AddToPlaylistBody(val videoId: Long)
|
||||
|
||||
@Serializable
|
||||
data class CreatePlaylistBody(val displayName: String)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistCreatedDto(val id: Long = 0)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistCreatedResponse(val videoPlaylist: PlaylistCreatedDto? = null)
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
package net.jeena.jtubetv.data.api
|
||||
|
||||
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<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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,30 @@ abstract class PagedListViewModel<T> : 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<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>>
|
||||
|
||||
/** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */
|
||||
|
|
|
|||
100
app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt
Normal file
100
app/src/main/java/net/jeena/jtubetv/ui/list/Snapshots.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoListItem>) {
|
||||
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<VideoListItem.Video>().take(50).map { it.video },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
412
app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
Normal file
412
app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
package net.jeena.jtubetv.ui.player
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.Button
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.launch
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
||||
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
|
||||
import net.jeena.jtubetv.data.api.CommentThreadDto
|
||||
import net.jeena.jtubetv.data.api.VideoDetailDto
|
||||
|
||||
/**
|
||||
* Right sidebar for the player (issue #10): "comments" or "playlists" panel.
|
||||
* While open, the surface shrinks the video with `end` padding of
|
||||
* [SIDEBAR_WIDTH]. Rows are D-pad navigable; BACK closes (handled by the
|
||||
* surface) and LEFT over any row closes too.
|
||||
*/
|
||||
val SIDEBAR_WIDTH = 420.dp
|
||||
|
||||
private val SidebarBg = Color(0xFF0E0E0E)
|
||||
private val TextMain = Color(0xFFE0E0E0)
|
||||
private val TextDim = Color(0xFF9A9A9A)
|
||||
private val Accent = Color(0xFFCE2B2B)
|
||||
|
||||
@Composable
|
||||
fun PlayerSidebar(
|
||||
mode: String, // "comments" | "playlists"
|
||||
detail: VideoDetailDto,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(SIDEBAR_WIDTH)
|
||||
.background(SidebarBg),
|
||||
) {
|
||||
Text(
|
||||
text = if (mode == "playlists") "Playlists" else "Comments",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
)
|
||||
if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */
|
||||
@Composable
|
||||
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
||||
val fr = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(120)
|
||||
runCatching { fr.requestFocus() }
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
color = TextDim,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.type == KeyEventType.KeyDown &&
|
||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
) {
|
||||
onDismiss()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.focusRequester(fr)
|
||||
.focusable(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */
|
||||
@Composable
|
||||
private fun SidebarRow(
|
||||
modifier: Modifier = Modifier,
|
||||
onLeft: (() -> Unit)? = null,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
var m = modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
|
||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
||||
if (onLeft != null) {
|
||||
m = m.onPreviewKeyEvent { ke ->
|
||||
if (ke.type == KeyEventType.KeyDown &&
|
||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
) {
|
||||
onLeft()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
m = m
|
||||
.dpadCenterActivatable(onClick ?: {})
|
||||
.focusable()
|
||||
if (onClick != null) m = m.clickable(onClick = onClick)
|
||||
Row(
|
||||
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
// ---- comments --------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||
val repository = AppGraph.repository
|
||||
// Comments load once per opening; paging via list end detection.
|
||||
var items by remember { mutableStateOf<List<CommentThreadDto>>(emptyList()) }
|
||||
var total by remember { mutableStateOf(0L) }
|
||||
var start by remember { mutableStateOf(0L) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
val firstRow = remember { FocusRequester() }
|
||||
|
||||
suspend fun fetchNext() {
|
||||
loading = true
|
||||
repository.comments(detail.shortUUID, start, 20).fold(
|
||||
onSuccess = { r ->
|
||||
items = items + r.data
|
||||
start = items.size.toLong()
|
||||
total = r.total
|
||||
},
|
||||
onFailure = {
|
||||
// Only fail the FIRST page; later page errors keep what we have.
|
||||
if (items.isEmpty()) failed = true
|
||||
},
|
||||
)
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { fetchNext() }
|
||||
|
||||
val lastVisibleIndex by remember {
|
||||
androidx.compose.runtime.derivedStateOf {
|
||||
listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
}
|
||||
}
|
||||
LaunchedEffect(lastVisibleIndex, total) {
|
||||
if (failed || loading || total <= 0) return@LaunchedEffect
|
||||
val last = lastVisibleIndex ?: return@LaunchedEffect
|
||||
if (start < total && last >= items.size - 2) fetchNext()
|
||||
}
|
||||
|
||||
// D-pad focus: land on the first comment when ready.
|
||||
LaunchedEffect(loading, failed, items.size) {
|
||||
if (!loading && !failed && items.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
runCatching { firstRow.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
loading && items.isEmpty() -> SidebarFallback("Loading…", onDismiss)
|
||||
failed -> SidebarFallback("Comments are not available here.", onDismiss)
|
||||
items.isEmpty() -> SidebarFallback("No comments yet.", onDismiss)
|
||||
else ->
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
items.forEachIndexed { index, comment ->
|
||||
item(key = "c" + comment.threadId) {
|
||||
CommentRow(
|
||||
comment = comment,
|
||||
onLeft = onDismiss,
|
||||
focusRequester = if (index == 0) firstRow else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (start < total) {
|
||||
item(key = "more") {
|
||||
Text(
|
||||
"Loading more…",
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommentRow(
|
||||
comment: CommentThreadDto,
|
||||
onLeft: () -> Unit,
|
||||
focusRequester: FocusRequester? = null,
|
||||
) {
|
||||
val avatar = comment.account?.avatars?.maxByOrNull { it.width }
|
||||
SidebarRow(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
onLeft = onLeft,
|
||||
focusRequester = focusRequester,
|
||||
) {
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
Column(Modifier.padding(start = 10.dp)) {
|
||||
Text(
|
||||
text = (comment.account?.displayName ?: "?") +
|
||||
" " + formatDateShort(comment.createdAt),
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Text(
|
||||
text = comment.text,
|
||||
color = TextMain,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
if (comment.totalReplies > 0) {
|
||||
Text(
|
||||
text = comment.totalReplies.toString() + " replies",
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- playlists ---------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun PlaylistsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
||||
val repository = AppGraph.repository
|
||||
val scope = rememberCoroutineScope()
|
||||
var playlists by remember { mutableStateOf<List<VideoPlaylistSummaryDto>>(emptyList()) }
|
||||
var containedBy by remember { mutableStateOf<Map<Long, Long>>(emptyMap()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
val inputFocus = remember { FocusRequester() }
|
||||
val rowsFocus = remember { FocusRequester() }
|
||||
|
||||
suspend fun refreshHits() {
|
||||
repository.playlistsContaining(detail.id).fold(
|
||||
onSuccess = { containedBy = it },
|
||||
onFailure = { },
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(detail.id) {
|
||||
repository.myPlaylists().fold(
|
||||
onSuccess = { playlists = it },
|
||||
onFailure = { failed = true },
|
||||
)
|
||||
refreshHits()
|
||||
loading = false
|
||||
}
|
||||
|
||||
fun toggle(playlist: VideoPlaylistSummaryDto) {
|
||||
val elementId = containedBy[playlist.id]
|
||||
scope.launch {
|
||||
if (elementId != null) {
|
||||
repository.removeFromPlaylist(playlist.id, elementId)
|
||||
} else {
|
||||
repository.addToPlaylist(playlist.id, detail.id)
|
||||
}
|
||||
// Membership checks settle server-side; re-check after a moment.
|
||||
kotlinx.coroutines.delay(1000)
|
||||
refreshHits()
|
||||
}
|
||||
}
|
||||
|
||||
fun create() {
|
||||
val name = newName.trim()
|
||||
if (name.isEmpty()) return
|
||||
scope.launch {
|
||||
repository.createPlaylist(name).onSuccess { playlist ->
|
||||
if (playlist != null) {
|
||||
playlists = listOf(playlist) + playlists
|
||||
newName = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loading) {
|
||||
if (!loading && !failed && playlists.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
runCatching { rowsFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// New playlist: text field + create button.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
singleLine = true,
|
||||
label = { Text("New playlist") },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(inputFocus)
|
||||
.padding(start = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { create() },
|
||||
modifier = Modifier.padding(start = 10.dp, end = 16.dp),
|
||||
) { Text("Create") }
|
||||
}
|
||||
when {
|
||||
loading -> SidebarFallback("Loading…", onDismiss)
|
||||
failed -> SidebarFallback("Could not load playlists", onDismiss)
|
||||
playlists.isEmpty() -> SidebarFallback("No playlists yet.", onDismiss)
|
||||
else ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
playlists.forEachIndexed { index, playlist ->
|
||||
val checked = containedBy.containsKey(playlist.id)
|
||||
item(key = "p" + playlist.id) {
|
||||
SidebarRow(
|
||||
onLeft = onDismiss,
|
||||
onClick = { toggle(playlist) },
|
||||
focusRequester = if (index == 0) rowsFocus else null,
|
||||
) {
|
||||
PlaylistCheckbox(checked)
|
||||
Text(
|
||||
text = playlist.displayName,
|
||||
color = TextMain,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCheckbox(checked: Boolean) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(if (checked) Accent else Color(0x33FFFFFF)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDateShort(iso: String?): String {
|
||||
if (iso.isNullOrBlank()) return ""
|
||||
return runCatching {
|
||||
java.time.ZonedDateTime.ofInstant(
|
||||
java.time.Instant.parse(iso),
|
||||
java.time.ZoneId.systemDefault(),
|
||||
).format(java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy", java.util.Locale.getDefault()))
|
||||
}.getOrElse { "" }
|
||||
}
|
||||
|
|
@ -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<UserDto?>(null)
|
||||
private set
|
||||
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
|
|
@ -115,9 +121,17 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
private set
|
||||
|
||||
private val repo: PeerTubeRepository = AppGraph.repository
|
||||
|
||||
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) {
|
||||
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 ->
|
||||
|
|
|
|||
|
|
@ -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<List<ContinueItem>>(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<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>) {
|
||||
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>> =
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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<String?>(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.
|
||||
// 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<Boolean?>(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
|
||||
.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,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 -> {
|
||||
|
|
@ -767,3 +902,56 @@ private fun formatDuration(seconds: Int): String {
|
|||
else -> "%d:%02d".format(m, s)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pill button of the video action row (issue #10): text or emoji-labelled; the
|
||||
* subscribed/liked states get a solid accent background. */
|
||||
@Composable
|
||||
private fun PlayerActionButton(
|
||||
label: String,
|
||||
filled: Boolean = false,
|
||||
focusRequester: FocusRequester? = null,
|
||||
// [onClick] receives an onDone callback; only call it when the action's
|
||||
// network roundtrip finished — events arriving before onDone are dropped.
|
||||
onClick: (onDone: () -> Unit) -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
// One activation per press: on some devices CENTER also triggers the platform
|
||||
// click; without a guard the double event would subscribe and immediately
|
||||
// unsubscribe (observed live). The guard also blocks spammage while a request
|
||||
// is in flight.
|
||||
var inFlight by remember { mutableStateOf(false) }
|
||||
fun activate() {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
onClick { inFlight = false }
|
||||
}
|
||||
|
||||
var m: Modifier = Modifier
|
||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
||||
m = m
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.dpadCenterActivatable({ activate() })
|
||||
.focusable()
|
||||
.background(
|
||||
when {
|
||||
focused -> Color(0x55FFFFFF)
|
||||
filled -> Color(0x33CE2B2B)
|
||||
else -> Color(0x22FFFFFF)
|
||||
},
|
||||
RoundedCornerShape(20.dp),
|
||||
)
|
||||
.then(
|
||||
if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp))
|
||||
else Modifier
|
||||
)
|
||||
// Click only for real pointer input; the key activation above is guarded.
|
||||
.pointerInput(Unit) { detectTapGestures { activate() } }
|
||||
Box(modifier = m.padding(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue