Compare commits
No commits in common. "4af904bf2ac909281329ddeab790800fcaa0981b" and "fdc570ac9ef017fc58bc2092757a47686fb4f271" have entirely different histories.
4af904bf2a
...
fdc570ac9e
13 changed files with 36 additions and 1089 deletions
|
|
@ -3,22 +3,15 @@ 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
|
||||||
|
|
@ -26,7 +19,6 @@ 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
|
||||||
|
|
@ -241,68 +233,6 @@ class PeerTubeRepository(
|
||||||
|
|
||||||
fun hasSession(): Boolean = store.hasSession()
|
fun hasSession(): Boolean = store.hasSession()
|
||||||
|
|
||||||
// ---- interactions (issue #10) ----------------------------------------
|
|
||||||
|
|
||||||
/** True if the user follows `channel@host`, false if not subscribed. */
|
|
||||||
suspend fun subscriptionExists(handle: String): Result<Boolean> = runCatching {
|
|
||||||
network.api.subscriptionsExist(listOf(handle))[handle] == true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun subscribe(handle: String): Result<Unit> = runCatching {
|
|
||||||
val r = network.api.subscribe(SubscribeBody(handle))
|
|
||||||
require(r.isSuccessful) { "subscribe failed: " + r.code() }
|
|
||||||
}.map { }
|
|
||||||
|
|
||||||
/** 404 (never subscribed) counts as unsubscribed. */
|
|
||||||
suspend fun unsubscribe(handle: String): Result<Unit> = runCatching {
|
|
||||||
network.api.unsubscribe(handle)
|
|
||||||
}.recoverCatching { e ->
|
|
||||||
if (e is retrofit2.HttpException && e.code() == 404) Unit else throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "none" | "like" | "dislike" */
|
|
||||||
suspend fun myRating(shortUuid: String): Result<String> =
|
|
||||||
runCatching { network.api.myRating(shortUuid).rating }
|
|
||||||
|
|
||||||
suspend fun rate(shortUuid: String, rating: String): Result<Unit> =
|
|
||||||
runCatching { network.api.rate(shortUuid, RatingBody(rating)) }
|
|
||||||
|
|
||||||
/** Top-level comment threads, newest first-ish (server default). */
|
|
||||||
suspend fun comments(shortUuid: String, start: Long, count: Int): Result<CommentThreadsResponse> =
|
|
||||||
runCatching { network.api.commentThreads(shortUuid, start, count) }
|
|
||||||
|
|
||||||
/** Playlists owned by the logged-in user (their account handle). */
|
|
||||||
suspend fun myPlaylists(): Result<List<VideoPlaylistSummaryDto>> = runCatching {
|
|
||||||
val account = store.accountName ?: throw IllegalStateException("No account")
|
|
||||||
val first = network.api.accountPlaylists(account, 0, 100)
|
|
||||||
val rest = if (first.total > first.data.size) {
|
|
||||||
network.api.accountPlaylists(account, first.data.size.toLong(), (first.total - first.data.size).toInt())
|
|
||||||
.data
|
|
||||||
} else emptyList()
|
|
||||||
first.data + rest
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Playlist id → element id for the playlists containing the video (numeric video id). */
|
|
||||||
suspend fun playlistsContaining(videoId: Long): Result<Map<Long, Long>> = runCatching {
|
|
||||||
network.api.playlistsContaining(videoId)
|
|
||||||
.values
|
|
||||||
.flatten()
|
|
||||||
.mapNotNull { element -> element.playlistId?.let { element.elementId?.let { e -> it to e } } }
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addToPlaylist(playlistId: Long, videoId: Long): Result<Unit> =
|
|
||||||
runCatching { network.api.addToPlaylist(playlistId, AddToPlaylistBody(videoId)) }
|
|
||||||
|
|
||||||
suspend fun removeFromPlaylist(playlistId: Long, elementId: Long): Result<Unit> =
|
|
||||||
runCatching { network.api.removeFromPlaylist(playlistId, elementId) }
|
|
||||||
|
|
||||||
suspend fun createPlaylist(displayName: String): Result<VideoPlaylistSummaryDto?> = runCatching {
|
|
||||||
val created = network.api.createPlaylist(CreatePlaylistBody(displayName))
|
|
||||||
.videoPlaylist ?: throw IllegalStateException("No playlist in response")
|
|
||||||
VideoPlaylistSummaryDto(id = created.id, displayName = displayName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full logout (feature request from UX review): forget URL, username and tokens. */
|
/** Full logout (feature request from UX review): forget URL, username and tokens. */
|
||||||
fun signOut() = store.clearAll()
|
fun signOut() = store.clearAll()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,6 @@ 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(),
|
||||||
|
|
@ -179,65 +177,3 @@ data class UserAccountDto(
|
||||||
@SerialName("displayName") val displayName: String? = null,
|
@SerialName("displayName") val displayName: String? = null,
|
||||||
val avatars: List<Avatar> = emptyList(),
|
val avatars: List<Avatar> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- interactions under the video (issue #10) ----------------------------
|
|
||||||
|
|
||||||
/** One thread comment; `account` reuses [ChannelOwnerAccount] (name + displayName + avatars). */
|
|
||||||
@Serializable
|
|
||||||
data class CommentThreadDto(
|
|
||||||
val threadId: Long = 0,
|
|
||||||
val text: String = "",
|
|
||||||
val createdAt: String? = null,
|
|
||||||
@SerialName("totalReplies") val totalReplies: Int = 0,
|
|
||||||
val account: ChannelOwnerAccount? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommentThreadsResponse(
|
|
||||||
val total: Long = 0,
|
|
||||||
val data: List<CommentThreadDto> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class VideoPlaylistSummaryDto(
|
|
||||||
val id: Long,
|
|
||||||
@SerialName("displayName") val displayName: String,
|
|
||||||
val privacy: Labelled? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class VideoPlaylistListResponse(
|
|
||||||
val total: Long = 0,
|
|
||||||
val data: List<VideoPlaylistSummaryDto> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Membership hit within one playlist (from videos-exist). */
|
|
||||||
@Serializable
|
|
||||||
data class PlaylistElementDto(
|
|
||||||
val playlistId: Long? = null,
|
|
||||||
@SerialName("playlistElementId") val elementId: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RateResponse(
|
|
||||||
val rating: String = "none",
|
|
||||||
)
|
|
||||||
|
|
||||||
// request bodies
|
|
||||||
@Serializable
|
|
||||||
data class SubscribeBody(val uri: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RatingBody(val rating: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AddToPlaylistBody(val videoId: Long)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CreatePlaylistBody(val displayName: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PlaylistCreatedDto(val id: Long = 0)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PlaylistCreatedResponse(val videoPlaylist: PlaylistCreatedDto? = null)
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -123,56 +120,4 @@ 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)
|
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ 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
|
||||||
|
|
@ -84,8 +83,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -25,30 +25,6 @@ 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. */
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package net.jeena.jtubetv.ui.list
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
|
||||||
import net.jeena.jtubetv.data.api.VideoChannelDto
|
|
||||||
import net.jeena.jtubetv.data.api.VideoDto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disk snapshots of the last successfully loaded lists (issue #9): the app
|
|
||||||
* shows them instantly on the next cold start while the network refresh runs
|
|
||||||
* in the background, so navigation never waits for the API again on the first
|
|
||||||
* paint. Storage lives in cacheDir (the OS may evict it) and is invalidated
|
|
||||||
* implicitly: every successful chunk overwrites the snapshot, logout clears it.
|
|
||||||
*/
|
|
||||||
object Snapshots {
|
|
||||||
|
|
||||||
private val json = net.jeena.jtubetv.data.network.peertubeJson
|
|
||||||
private const val DIR = "list-snapshots"
|
|
||||||
|
|
||||||
private fun file(context: Context, key: String) =
|
|
||||||
java.io.File(context.cacheDir, DIR + "/" + key.replace(Regex("[^A-Za-z0-9._-]"), "_") + ".json")
|
|
||||||
|
|
||||||
private fun write(context: Context, key: String, content: String) {
|
|
||||||
val f = file(context, key)
|
|
||||||
f.parentFile?.mkdirs()
|
|
||||||
f.writeText(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun read(context: Context, key: String): String? =
|
|
||||||
file(context, key).takeIf { it.exists() }?.readText()
|
|
||||||
|
|
||||||
fun saveVideos(context: Context, key: String, videos: List<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,7 +10,6 @@ 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 {
|
||||||
|
|
@ -42,37 +41,6 @@ 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 {
|
||||||
|
|
@ -82,20 +50,6 @@ class VideoListViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Persist the freshly loaded content so the next app start paints
|
|
||||||
// instantly (issue #9).
|
|
||||||
if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) {
|
|
||||||
val context = AppGraph.appContext
|
|
||||||
if (context != null) {
|
|
||||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
|
||||||
Snapshots.saveVideos(
|
|
||||||
context,
|
|
||||||
cacheKey(),
|
|
||||||
items.filterIsInstance<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>> {
|
||||||
|
|
@ -120,24 +74,7 @@ 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) {
|
||||||
// Instant restore from the snapshot, then a fresh fetch (issue #9).
|
viewModelScope.launch { channelHeader = repository.videoChannel(s.handle).getOrNull() }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,412 +0,0 @@
|
||||||
package net.jeena.jtubetv.ui.player
|
|
||||||
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
|
||||||
import androidx.compose.ui.input.key.type
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import net.jeena.jtubetv.AppGraph
|
|
||||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
|
||||||
import net.jeena.jtubetv.data.api.VideoPlaylistSummaryDto
|
|
||||||
import net.jeena.jtubetv.data.api.CommentThreadDto
|
|
||||||
import net.jeena.jtubetv.data.api.VideoDetailDto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Right sidebar for the player (issue #10): "comments" or "playlists" panel.
|
|
||||||
* While open, the surface shrinks the video with `end` padding of
|
|
||||||
* [SIDEBAR_WIDTH]. Rows are D-pad navigable; BACK closes (handled by the
|
|
||||||
* surface) and LEFT over any row closes too.
|
|
||||||
*/
|
|
||||||
val SIDEBAR_WIDTH = 420.dp
|
|
||||||
|
|
||||||
private val SidebarBg = Color(0xFF0E0E0E)
|
|
||||||
private val TextMain = Color(0xFFE0E0E0)
|
|
||||||
private val TextDim = Color(0xFF9A9A9A)
|
|
||||||
private val Accent = Color(0xFFCE2B2B)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PlayerSidebar(
|
|
||||||
mode: String, // "comments" | "playlists"
|
|
||||||
detail: VideoDetailDto,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(SIDEBAR_WIDTH)
|
|
||||||
.background(SidebarBg),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (mode == "playlists") "Playlists" else "Comments",
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.padding(20.dp),
|
|
||||||
)
|
|
||||||
if (mode == "playlists") PlaylistsColumn(detail, onDismiss) else CommentsColumn(detail, onDismiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Focus catcher for empty/loading sidebars: keeps LEFT-working (closes). */
|
|
||||||
@Composable
|
|
||||||
private fun SidebarFallback(message: String, onDismiss: () -> Unit) {
|
|
||||||
val fr = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
kotlinx.coroutines.delay(120)
|
|
||||||
runCatching { fr.requestFocus() }
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
color = TextDim,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp)
|
|
||||||
.onPreviewKeyEvent { ke ->
|
|
||||||
if (ke.type == KeyEventType.KeyDown &&
|
|
||||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
|
||||||
) {
|
|
||||||
onDismiss()
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
.focusRequester(fr)
|
|
||||||
.focusable(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sidebar row: focusable with visual focus; LEFT closes the sidebar. */
|
|
||||||
@Composable
|
|
||||||
private fun SidebarRow(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onLeft: (() -> Unit)? = null,
|
|
||||||
focusRequester: FocusRequester? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
var m = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onFocusChanged { focused = it.isFocused }
|
|
||||||
.background(if (focused) Color(0x1AFFFFFF) else Color.Transparent)
|
|
||||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
|
||||||
if (onLeft != null) {
|
|
||||||
m = m.onPreviewKeyEvent { ke ->
|
|
||||||
if (ke.type == KeyEventType.KeyDown &&
|
|
||||||
ke.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
|
||||||
) {
|
|
||||||
onLeft()
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m = m
|
|
||||||
.dpadCenterActivatable(onClick ?: {})
|
|
||||||
.focusable()
|
|
||||||
if (onClick != null) m = m.clickable(onClick = onClick)
|
|
||||||
Row(
|
|
||||||
modifier = m.padding(horizontal = 20.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- comments --------------------------------------------------------------
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CommentsColumn(detail: VideoDetailDto, onDismiss: () -> Unit) {
|
|
||||||
val repository = AppGraph.repository
|
|
||||||
// Comments load once per opening; paging via list end detection.
|
|
||||||
var items by remember { mutableStateOf<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,7 +59,6 @@ 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
|
||||||
|
|
@ -79,7 +78,6 @@ import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
||||||
* the full listing (max 100/page) and fetch per-channel stats (videos + sum of
|
* 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,
|
||||||
|
|
@ -89,10 +87,6 @@ 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())
|
||||||
|
|
@ -121,16 +115,8 @@ 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>()
|
|
||||||
|
|
||||||
init {
|
private val statsMap = HashMap<String, ChannelStats>()
|
||||||
// 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) {
|
||||||
|
|
@ -255,12 +241,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
|
.thenByDescending { g -> g.channels.sumOf { 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 ->
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ 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. */
|
||||||
|
|
@ -51,13 +50,10 @@ 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)
|
||||||
|
|
@ -68,38 +64,11 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,12 +117,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,29 +131,8 @@ 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>> =
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// Persistent top bar: logo/title left, account indicator right.
|
// Persistent top bar: logo/title left, account button right.
|
||||||
TopBar(
|
TopBar(
|
||||||
|
onAccountClick = { drawerOpen = !drawerOpen },
|
||||||
profile = userProfile,
|
profile = userProfile,
|
||||||
)
|
)
|
||||||
androidx.compose.runtime.key(epoch) {
|
androidx.compose.runtime.key(epoch) {
|
||||||
|
|
@ -278,11 +279,13 @@ 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.fillMaxWidth()
|
modifier = modifier
|
||||||
|
.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,
|
||||||
) {
|
) {
|
||||||
|
|
@ -294,7 +297,7 @@ private fun TopBar(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Bottom)
|
.align(Alignment.Bottom)
|
||||||
.padding(bottom = 16.dp) // sit the logo a touch below the text baseline
|
.padding(bottom = 17.dp) // slightly below the text baseline
|
||||||
.size(32.dp) // match the avatar row so both headers align
|
.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)),
|
||||||
|
|
@ -317,10 +320,11 @@ 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 — TV focus and
|
// Account indicator: avatar + display name. Purely visual on TV (D-pad
|
||||||
// activation stay in the content/drawer (issue #7's "all the way up"
|
// focus and activation stay in the content/drawer); a click (mouse/touch)
|
||||||
// surprise must not return); the drawer is reached via the LEFT edge.
|
// still opens the menu drawer.
|
||||||
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),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,11 @@ 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
|
||||||
|
|
@ -81,13 +78,10 @@ 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 {
|
||||||
|
|
@ -279,11 +273,7 @@ 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)
|
||||||
|
|
@ -456,18 +446,10 @@ private fun PlayerSurface(
|
||||||
.onPreviewKeyEvent { ke ->
|
.onPreviewKeyEvent { ke ->
|
||||||
val code = ke.nativeKeyEvent.keyCode
|
val code = ke.nativeKeyEvent.keyCode
|
||||||
when {
|
when {
|
||||||
// BACK/Esc: close the sidebar first, else leave the player.
|
// BACK/Esc leave the player in any state, from any focus.
|
||||||
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
|
code == KeyEvent.KEYCODE_BACK || code == KeyEvent.KEYCODE_ESCAPE -> {
|
||||||
if (sidebar != null) {
|
onBack(); true
|
||||||
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.
|
||||||
|
|
@ -480,7 +462,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 bar.
|
// UP/DOWN etc. reveal OUR controls, focus the Play button.
|
||||||
controlsVisible = true
|
controlsVisible = true
|
||||||
if (surfaceFocused) {
|
if (surfaceFocused) {
|
||||||
runCatching { seekBarFocus.requestFocus() }
|
runCatching { seekBarFocus.requestFocus() }
|
||||||
|
|
@ -515,10 +497,7 @@ private fun PlayerSurface(
|
||||||
setOnClickListener { controlsVisible = true }
|
setOnClickListener { controlsVisible = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// With the sidebar open the video shrinks to the left (issue #10).
|
modifier = Modifier.fillMaxSize(),
|
||||||
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
|
||||||
|
|
@ -532,8 +511,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).
|
||||||
// action row). Persistent when paused, transient while playing.
|
// Persistent when paused, transient while playing.
|
||||||
if (paused || controlsVisible) {
|
if (paused || controlsVisible) {
|
||||||
PausedControls(
|
PausedControls(
|
||||||
detail = s.detail,
|
detail = s.detail,
|
||||||
|
|
@ -541,28 +520,10 @@ 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,15 +556,9 @@ 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) {
|
||||||
|
|
@ -628,6 +583,7 @@ 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)
|
||||||
|
|
@ -677,113 +633,29 @@ private fun PausedControls(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The seek bar is the only playback control: focused = scrub, OK toggles.
|
// The seek bar is the only control: focused = scrub, OK = play/pause.
|
||||||
SeekBarRow(
|
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,
|
||||||
actionRowFocus = actionBarFocus,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
|
||||||
)
|
)
|
||||||
// Action row under the red line (issue #10): subscribe, like/dislike,
|
// Position / duration under the bar, right-aligned.
|
||||||
// comments + playlists open the right sidebar; times on the right.
|
Text(
|
||||||
val repository = AppGraph.repository
|
text = formatDuration((positionMs.longValue / 1000).toInt()) + " / " +
|
||||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
formatDuration((durationMs / 1000).toInt()),
|
||||||
var subscribed by remember { mutableStateOf<Boolean?>(null) }
|
color = Color(0xFFCCCCCC),
|
||||||
var rating by remember { mutableStateOf("none") }
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
// 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
|
||||||
.fillMaxWidth()
|
.align(Alignment.End)
|
||||||
.padding(top = 10.dp),
|
.padding(top = 8.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(
|
||||||
|
|
@ -792,7 +664,6 @@ 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) }
|
||||||
|
|
@ -813,14 +684,8 @@ 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 -> {
|
KeyEvent.KEYCODE_DPAD_UP -> channelFocus?.let { runCatching { it.requestFocus() }; true } ?: false
|
||||||
if (channelFocus != null) runCatching { channelFocus.requestFocus() }
|
KeyEvent.KEYCODE_DPAD_DOWN -> true
|
||||||
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 -> {
|
||||||
|
|
@ -902,56 +767,3 @@ private fun formatDuration(seconds: Int): String {
|
||||||
else -> "%d:%02d".format(m, s)
|
else -> "%d:%02d".format(m, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pill button of the video action row (issue #10): text or emoji-labelled; the
|
|
||||||
* subscribed/liked states get a solid accent background. */
|
|
||||||
@Composable
|
|
||||||
private fun PlayerActionButton(
|
|
||||||
label: String,
|
|
||||||
filled: Boolean = false,
|
|
||||||
focusRequester: FocusRequester? = null,
|
|
||||||
// [onClick] receives an onDone callback; only call it when the action's
|
|
||||||
// network roundtrip finished — events arriving before onDone are dropped.
|
|
||||||
onClick: (onDone: () -> Unit) -> Unit,
|
|
||||||
) {
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
// One activation per press: on some devices CENTER also triggers the platform
|
|
||||||
// click; without a guard the double event would subscribe and immediately
|
|
||||||
// unsubscribe (observed live). The guard also blocks spammage while a request
|
|
||||||
// is in flight.
|
|
||||||
var inFlight by remember { mutableStateOf(false) }
|
|
||||||
fun activate() {
|
|
||||||
if (inFlight) return
|
|
||||||
inFlight = true
|
|
||||||
onClick { inFlight = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
var m: Modifier = Modifier
|
|
||||||
if (focusRequester != null) m = m.focusRequester(focusRequester)
|
|
||||||
m = m
|
|
||||||
.onFocusChanged { focused = it.isFocused }
|
|
||||||
.dpadCenterActivatable({ activate() })
|
|
||||||
.focusable()
|
|
||||||
.background(
|
|
||||||
when {
|
|
||||||
focused -> Color(0x55FFFFFF)
|
|
||||||
filled -> Color(0x33CE2B2B)
|
|
||||||
else -> Color(0x22FFFFFF)
|
|
||||||
},
|
|
||||||
RoundedCornerShape(20.dp),
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (focused) Modifier.border(2.dp, Color.White, RoundedCornerShape(20.dp))
|
|
||||||
else Modifier
|
|
||||||
)
|
|
||||||
// Click only for real pointer input; the key activation above is guarded.
|
|
||||||
.pointerInput(Unit) { detectTapGestures { activate() } }
|
|
||||||
Box(modifier = m.padding(horizontal = 14.dp, vertical = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue