app: Scaffold Android TV PeerTube client (MVP)

Start the SmartTube-inspired PeerTube TV client as a Kotlin + Compose
for TV app. It targets any PeerTube instance (v1 API), with JTube as
the default, and covers login 0, local/own video listings 1-2, search
3, subscriptions 4, server-backed history 5 and HLS/progressive
playback with watching reports 6.

Password is only used for the OAuth password grant and never stored;
tokens are kept in EncryptedSharedPreferences. Building needs JDK 17+
(see README); the Gradle wrapper is checked in so no local Gradle is
required.
This commit is contained in:
Jeena 2026-10-10 10:20:02 +00:00
commit 59029438ad
40 changed files with 2605 additions and 0 deletions

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".JTubeApp"
android:allowBackup="false"
android:banner="@drawable/app_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.JTubeTV">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,26 @@
package net.jeena.jtubetv
import android.app.Application
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.network.PeerTubeNetwork
/** Tiny service locator; deliberately not a DI framework. */
object AppGraph {
lateinit var repository: PeerTubeRepository
private set
lateinit var tokenStore: TokenStore
private set
fun init(context: Application) {
tokenStore = TokenStore(context)
repository = PeerTubeRepository(tokenStore, PeerTubeNetwork(tokenStore))
}
}
class JTubeApp : Application() {
override fun onCreate() {
super.onCreate()
AppGraph.init(this)
}
}

View file

@ -0,0 +1,53 @@
package net.jeena.jtubetv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import net.jeena.jtubetv.ui.AppViewModel
import net.jeena.jtubetv.ui.SessionState
import net.jeena.jtubetv.ui.screens.HomeScreen
import net.jeena.jtubetv.ui.screens.SetupScreen
import net.jeena.jtubetv.ui.theme.JTubeTvTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
JTubeTvTheme {
AppRoot()
}
}
}
}
@Composable
private fun AppRoot(viewModel: AppViewModel = viewModel()) {
val session by viewModel.sessionState.collectAsState()
Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (session) {
is SessionState.Loading ->
CircularProgressIndicator()
is SessionState.NeedsSetup ->
SetupScreen(
loggingIn = viewModel.loggingIn,
error = viewModel.loginError,
onLogin = { url, user, pass -> viewModel.login(url, user, pass) },
)
is SessionState.Ready ->
HomeScreen()
}
}
}
}

View file

@ -0,0 +1,144 @@
package net.jeena.jtubetv.data
import kotlinx.serialization.json.Json
import net.jeena.jtubetv.data.api.HistoryElement
import net.jeena.jtubetv.data.api.OAuthClient
import net.jeena.jtubetv.data.api.PeerTubeApi
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.WatchingBody
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.network.PeerTubeNetwork
import net.jeena.jtubetv.data.network.apiUrlOf
import net.jeena.jtubetv.data.network.normalizeBaseUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/** All suspend access to the PeerTube API lives here. Errors surface via [Result]. */
class PeerTubeRepository(
private val store: TokenStore,
private val network: PeerTubeNetwork,
) {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private val baseUrl: String
get() = store.baseUrl ?: ""
// ---- login -------------------------------------------------------------
suspend fun login(rawBaseUrl: String, username: String, password: String): Result<Unit> =
runCatching {
val base = normalizeBaseUrl(rawBaseUrl)
val retrofit = Retrofit.Builder()
.baseUrl(apiUrlOf(base) + "/")
.client(plainOkHttp())
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
.build()
val api = retrofit.create(PeerTubeApi::class.java)
val client: OAuthClient = api.oauthClients()
val token: TokenResponse =
try {
api.token("password", client.clientId, client.clientSecret, username, password)
} catch (e: retrofit2.HttpException) {
if (e.code() == 400 || e.code() == 401) throw BadCredentialsException()
throw e
}
store.save(
baseUrl = base,
username = username,
clientId = client.clientId,
clientSecret = client.clientSecret,
accessToken = token.accessToken,
refreshToken = token.refreshToken,
expiresAtMillis = System.currentTimeMillis() +
(token.expiresIn ?: 86400L) * 1000L,
)
}
suspend fun me(): Result<UserDto> = runCatching { network.api.me() }
// ---- video lists -------------------------------------------------------
suspend fun localVideos(start: Long, count: Int, search: String? = null): Result<VideoListResponse> =
runCatching { network.api.localVideos(start, count, search = search) }
suspend fun myVideos(start: Long, count: Int): Result<VideoListResponse> =
runCatching { network.api.myVideos(start, count) }
suspend fun subscriptionVideos(start: Long, count: Int): Result<VideoListResponse> =
runCatching { network.api.subscriptionVideos(start, count) }
suspend fun history(start: Long, count: Int): Result<List<HistoryElement>> =
runCatching {
val response: VideoListResponse = network.api.history(start, count)
response.data.map { HistoryElement(video = it) }
}
suspend fun channelVideos(handle: String, start: Long, count: Int): Result<VideoListResponse> =
runCatching { network.api.channelVideos(handle, start, count) }
// ---- channels ----------------------------------------------------------
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
runCatching { network.api.channels(start = start, count = count, search = search) }
suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username)
response.data
}
// ---- playback ----------------------------------------------------------
suspend fun video(shortUuid: String): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid) }
suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> =
runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) }
// ---- media URLs --------------------------------------------------------
/**
* URL of the best playable stream. Prefers HLS; falls back to progressive file.
* null when the instance exposes neither.
*/
fun playoutUrl(detail: VideoDetailDto): UrlToPlay? {
val hls = detail.streamingPlaylists.firstOrNull()
?.playlistUrl?.takeIf { it.isNotBlank() }?.let(::absoluteUrl)
if (hls != null) return UrlToPlay(hls, isHls = true)
val fileUrl = detail.files.firstOrNull()?.fileUrl?.takeIf { it.isNotBlank() }?.let(::absoluteUrl)
if (fileUrl != null) return UrlToPlay(fileUrl, isHls = false)
return null
}
/** Makes PeerTube-relative paths (e.g. /thumbnails/…) absolute against the instance. */
fun absoluteUrl(pathOrUrl: String): String =
if (pathOrUrl.startsWith("http")) pathOrUrl
else baseUrl + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl
fun hasSession(): Boolean = store.hasSession()
class BadCredentialsException : Exception("Invalid credentials")
private fun plainOkHttp(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
data class UrlToPlay(val url: String, val isHls: Boolean)

View file

@ -0,0 +1,121 @@
package net.jeena.jtubetv.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OAuthClient(
@SerialName("client_id") val clientId: String,
@SerialName("client_secret") val clientSecret: String,
)
@Serializable
data class TokenResponse(
@SerialName("token_type") val tokenType: String,
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Long? = null,
)
@Serializable
data class Labelled(
val id: Int,
val label: String? = null,
)
@Serializable
data class Avatar(
val width: Int = 0,
val height: Int = 0,
val path: String = "",
val fileUrl: String? = null,
)
@Serializable
data class VideoChannelSummary(
val name: String,
@SerialName("displayName") val displayName: String,
val host: String,
val avatars: List<Avatar> = emptyList(),
)
@Serializable
data class VideoDto(
val id: Long,
val uuid: String,
val shortUUID: String,
val name: String,
val duration: Int = 0,
@SerialName("publishedAt") val publishedAt: String? = null,
val thumbnailPath: String? = null,
val privacy: Labelled? = null,
val nsfw: Boolean = false,
val views: Long = 0,
val channel: VideoChannelSummary,
)
@Serializable
data class VideoListResponse(
val total: Long,
val data: List<VideoDto>,
)
@Serializable
data class VideoChannelDto(
val name: String,
@SerialName("displayName") val displayName: String,
val host: String,
val description: String? = null,
val avatars: List<Avatar> = emptyList(),
val videosCount: Long = 0,
)
@Serializable
data class VideoChannelListResponse(
val total: Long,
val data: List<VideoChannelDto>,
)
@Serializable
data class VideoFile(
val id: Long,
val fileUrl: String? = null,
val resolution: Labelled? = null,
val mimeType: String? = null,
)
@Serializable
data class StreamingPlaylist(
val type: Int? = null,
val playlistUrl: String? = null,
val files: List<VideoFile> = emptyList(),
)
@Serializable
data class VideoDetailDto(
val id: Long,
val uuid: String,
val shortUUID: String,
val name: String,
val duration: Int = 0,
val description: String? = null,
@SerialName("publishedAt") val publishedAt: String? = null,
val thumbnailPath: String? = null,
val privacy: Labelled? = null,
val views: Long = 0,
val nsfw: Boolean = false,
val channel: VideoChannelSummary,
val files: List<VideoFile> = emptyList(),
val streamingPlaylists: List<StreamingPlaylist> = emptyList(),
)
@Serializable
data class HistoryElement(
val video: VideoDto,
@SerialName("lastViewedAt") val lastViewedAt: String? = null,
)
@Serializable
data class UserDto(
val username: String,
)

View file

@ -0,0 +1,108 @@
package net.jeena.jtubetv.data.api
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface PeerTubeApi {
@GET("oauth-clients/local")
suspend fun oauthClients(): OAuthClient
@FormUrlEncoded
@POST("users/token")
suspend fun token(
@Field("grant_type") grantType: String,
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("username") username: String,
@Field("password") password: String,
): TokenResponse
@FormUrlEncoded
@POST("users/token")
suspend fun refreshToken(
@Field("grant_type") grantType: String,
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("refresh_token") refreshToken: String,
): TokenResponse
@GET("videos")
suspend fun localVideos(
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-publishedAt",
@Query("isLocal") isLocal: Boolean = true,
@Query("search") search: String? = null,
): VideoListResponse
@GET("users/me/videos")
suspend fun myVideos(
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-publishedAt",
): VideoListResponse
@GET("users/me/subscriptions/videos")
suspend fun subscriptionVideos(
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-publishedAt",
): VideoListResponse
@GET("users/me/history/videos")
suspend fun history(
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-publishedAt",
): VideoListResponse
@GET("search/video-channels")
suspend fun searchChannels(
@Query("search") search: String,
@Query("searchTarget") searchTarget: String = "local",
@Query("start") start: Long,
@Query("count") count: Int,
): VideoChannelListResponse
@GET("video-channels")
suspend fun channels(
@Query("search") search: String,
@Query("searchTarget") searchTarget: String = "local",
@Query("start") start: Long,
@Query("count") count: Int,
): VideoChannelListResponse
@GET("accounts/{account}/video-channels")
suspend fun accountChannels(
@Path("account") account: String,
): VideoChannelListResponse
@GET("video-channels/{handle}/videos")
suspend fun channelVideos(
@Path("handle") handle: String,
@Query("start") start: Long,
@Query("count") count: Int,
@Query("sort") sort: String = "-publishedAt",
@Query("isLocal") isLocal: Boolean = true,
): VideoListResponse
@GET("videos/{shortUuid}")
suspend fun video(
@Path("shortUuid") shortUuid: String,
): VideoDetailDto
@POST("videos/{shortUuid}/watching")
suspend fun watching(
@Path("shortUuid") shortUuid: String,
@Body body: WatchingBody,
)
@GET("users/me")
suspend fun me(): UserDto
}

View file

@ -0,0 +1,8 @@
package net.jeena.jtubetv.data.api
import kotlinx.serialization.Serializable
@Serializable
data class WatchingBody(
val currentTime: Int,
)

View file

@ -0,0 +1,97 @@
package net.jeena.jtubetv.data.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Stores the session in [EncryptedSharedPreferences]: instance base URL, username,
* OAuth client credentials and both tokens. The user's password is never persisted.
*/
class TokenStore(context: Context) {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"jtube_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
var baseUrl: String?
get() = prefs.getString("baseUrl", null)
private set(value) = prefs.edit().putString("baseUrl", value).apply()
var username: String?
get() = prefs.getString("username", null)
private set(value) = prefs.edit().putString("username", value).apply()
var clientId: String?
get() = prefs.getString("clientId", null)
private set(value) = prefs.edit().putString("clientId", value).apply()
var clientSecret: String?
get() = prefs.getString("clientSecret", null)
private set(value) = prefs.edit().putString("clientSecret", value).apply()
var accessToken: String?
get() = prefs.getString("accessToken", null)
private set(value) = prefs.edit().putString("accessToken", value).apply()
var refreshToken: String?
get() = prefs.getString("refreshToken", null)
private set(value) = prefs.edit().putString("refreshToken", value).apply()
var tokenExpiresAtMillis: Long
get() = prefs.getLong("tokenExpiresAtMillis", 0L)
private set(value) = prefs.edit().putLong("tokenExpiresAtMillis", value).apply()
fun hasSession(): Boolean =
!baseUrl.isNullOrBlank() && !accessToken.isNullOrBlank()
/** Full login data, stored at once so the app never observes a half-written session. */
fun save(
baseUrl: String,
username: String,
clientId: String,
clientSecret: String,
accessToken: String,
refreshToken: String,
expiresAtMillis: Long,
) {
prefs.edit()
.putString("baseUrl", baseUrl)
.putString("username", username)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.putString("accessToken", accessToken)
.putString("refreshToken", refreshToken)
.putLong("tokenExpiresAtMillis", expiresAtMillis)
.apply()
}
fun setTokens(accessToken: String, refreshToken: String, expiresAtMillis: Long) {
prefs.edit()
.putString("accessToken", accessToken)
.putString("refreshToken", refreshToken)
.putLong("tokenExpiresAtMillis", expiresAtMillis)
.apply()
}
fun clearTokens() {
prefs.edit()
.remove("accessToken")
.remove("refreshToken")
.apply()
}
fun clearAll() {
prefs.edit().clear().apply()
}
}

View file

@ -0,0 +1,163 @@
package net.jeena.jtubetv.data.network
import kotlinx.serialization.json.Json
import net.jeena.jtubetv.data.api.PeerTubeApi
import net.jeena.jtubetv.data.api.TokenResponse
import net.jeena.jtubetv.data.auth.TokenStore
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
val peertubeJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** Normalizes a user-entered instance URL, e.g. "tube.jeena.net" or "https://x/". */
fun normalizeBaseUrl(raw: String): String {
var url = raw.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) url = "https://$url"
url = url.trimEnd('/')
return url
}
fun apiUrlOf(base: String): String = base + "/api/v1"
/**
* OkHttp client + Retrofit api for the currently configured instance.
* - Every request is redirected to the stored base URL (single Retrofit instance).
* - Every request carries the bearer token when one exists.
* - On 401 the authenticator performs a single-flight refresh-token call and retries once.
*/
class PeerTubeNetwork(private val store: TokenStore) {
/** Client without auth pieces — used for the refresh call itself. */
val plainClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.url(resolveUrl(chain.request()))
.apply {
store.accessToken?.takeIf { it.isNotBlank() }?.let {
header("Authorization", "Bearer $it")
}
}
.build()
chain.proceed(request)
}
.authenticator(TokenAuthenticator(store, ::refreshTokenBlocking))
.build()
}
val api by lazy {
Retrofit.Builder()
.baseUrl(PLACEHOLDER_BASE_URL)
.client(client)
.addConverterFactory(
peertubeJson.asConverterFactory("application/json; charset=utf-8".toMediaType())
)
.build()
.create(PeerTubeApi::class.java)
}
private fun resolveUrl(request: Request): okhttp3.HttpUrl {
val parsed = store.baseUrl?.let(::normalizeBaseUrl)?.toHttpUrlOrNull() ?: return request.url
return request.url.newBuilder()
.scheme(parsed.scheme)
.host(parsed.host)
.port(parsed.port)
.build()
}
/**
* Blocking refresh; OkHttp guarantees only one authenticator runs per client at a
* time, so calls that race on the same 401 see the *new* token in the store and
* proceed without a redundant network round-trip (first check below).
*/
@Synchronized
fun refreshTokenBlocking(): String? {
val refreshToken = store.refreshToken ?: return null
val clientId = store.clientId ?: return null
val clientSecret = store.clientSecret ?: return null
val base = store.baseUrl ?: return null
// Another thread may already have refreshed us.
if (store.accessToken != null &&
store.tokenExpiresAtMillis > System.currentTimeMillis() + EXPIRY_SLACK_MILLIS
) return store.accessToken
val form = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", refreshToken)
.build()
val response = try {
plainClient.newCall(
Request.Builder()
.url(apiUrlOf(base) + "/users/token")
.post(form)
.build()
).execute()
} catch (_: Exception) {
return null
}
response.use {
if (!it.isSuccessful) {
store.clearTokens()
return null
}
val parsed = peertubeJson.decodeFromString<TokenResponse>(it.body!!.string())
val expiresAt = System.currentTimeMillis() + (parsed.expiresIn ?: 86400L) * 1000L
store.setTokens(parsed.accessToken, parsed.refreshToken, expiresAt)
return parsed.accessToken
}
}
companion object {
const val PLACEHOLDER_BASE_URL = "https://placeholder.invalid/api/v1/"
const val EXPIRY_SLACK_MILLIS = 60_000L
}
}
class TokenAuthenticator(
private val store: TokenStore,
private val refresh: () -> String?,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null // refreshed already, still 401 → give up
val newToken = refresh() ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}

View file

@ -0,0 +1,47 @@
package net.jeena.jtubetv.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
sealed interface SessionState {
data object Loading : SessionState
data object NeedsSetup : SessionState
data object Ready : SessionState
}
class AppViewModel(private val repository: PeerTubeRepository = AppGraph.repository) : ViewModel() {
val sessionState: StateFlow<SessionState> = _sessionState
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
val loginError = MutableStateFlow<String?>(null)
val loggingIn = MutableStateFlow(false)
init {
_sessionState.value =
if (repository.hasSession()) SessionState.Ready else SessionState.NeedsSetup
}
fun login(baseUrl: String, username: String, password: String) {
viewModelScope.launch {
loggingIn.value = true
loginError.value = null
val result = repository.login(baseUrl, username, password)
loggingIn.value = false
if (result.isSuccess) {
_sessionState.value = SessionState.Ready
} else {
loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) {
"Invalid credentials"
} else {
"Could not reach instance: " + (result.exceptionOrNull()?.message ?: "unknown error")
}
}
}
}
}

View file

@ -0,0 +1,68 @@
package net.jeena.jtubetv.ui.list
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
/**
* Minimal infinite-scroll helper: triggers the next page when the consumer reaches
* [LOOKAHEAD] items from the end. One instance per list screen instance.
*/
abstract class PagedListViewModel<T> : ViewModel() {
protected val pageSize = 25
private var inFlight = false
var exhausted by mutableStateOf(false)
private set
var items by mutableStateOf<List<T>>(emptyList())
private set
var initialError by mutableStateOf<String?>(null)
private set
var loadingInitial by mutableStateOf(true)
private set
protected abstract suspend fun fetch(start: Long, count: Int): Result<List<T>>
fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return
inFlight = true
initialError = null
viewModelScope.launch {
try {
val result = fetch(items.size.toLong(), pageSize)
result.fold(
onSuccess = { chunk ->
exhausted = chunk.size < pageSize
items = items + chunk
loadingInitial = false
},
onFailure = { e ->
if (items.isEmpty()) {
initialError = e.message ?: "Unknown error"
loadingInitial = false
}
exhausted = true
},
)
} finally {
inFlight = false
}
}
}
fun retryInitial() {
items = emptyList()
exhausted = false
loadingInitial = true
loadMore(force = true)
}
companion object {
/** Call loadMore() when the last visible index is within this distance of the end. */
const val LOOKAHEAD = 5
}
}

View file

@ -0,0 +1,70 @@
package net.jeena.jtubetv.ui.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
/** Where the list's content comes from. */
sealed interface ListSource {
data object LocalVideos : ListSource
data object MyVideos : ListSource
data object Subscriptions : ListSource
data object History : ListSource
data object Search : ListSource
data class Channel(val handle: String, val displayName: String) : ListSource
}
class VideoListViewModel(
private val repository: PeerTubeRepository,
val source: ListSource,
) : PagedListViewModel<VideoListItem>() {
val query = MutableStateFlow("")
private var activeSearch: String? = null
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
return when (val s = source) {
is ListSource.LocalVideos ->
repository.localVideos(start, count).map { it.data.map { v -> VideoListItem.Video(v) } }
is ListSource.MyVideos ->
repository.myVideos(start, count).map { it.data.map { v -> VideoListItem.Video(v) } }
is ListSource.Subscriptions ->
repository.subscriptionVideos(start, count).map { it.data.map { v -> VideoListItem.Video(v) } }
is ListSource.History ->
repository.history(start, count).map { it.map { e -> VideoListItem.HistoryEntry(e) } }
is ListSource.Search -> {
val q = activeSearch ?: return Result.success(emptyList())
repository.localVideos(start, count, search = q).map { it.data.map { v -> VideoListItem.Video(v) } }
}
is ListSource.Channel ->
repository.channelVideos(s.handle, start, count).map { it.data.map { v -> VideoListItem.Video(v) } }
}
}
fun triggerSearch(raw: String) {
query.value = raw
activeSearch = raw.trim().ifBlank { null }
retryInitial()
}
fun loadInitial() {
if (items.isEmpty() && loadingInitial) loadMore(force = true)
}
/** History/search entries can hide the underlying video shape. */
sealed interface VideoListItem {
data class Video(val video: net.jeena.jtubetv.data.api.VideoDto) : VideoListItem
data class HistoryEntry(val history: net.jeena.jtubetv.data.api.HistoryElement) : VideoListItem
}
companion object {
fun factory(source: ListSource): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
VideoListViewModel(AppGraph.repository, source) as T
}
}
}

View file

@ -0,0 +1,154 @@
package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.ui.list.PagedListViewModel
class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoChannelDto>() {
override suspend fun fetch(start: Long, count: Int): Result<List<VideoChannelDto>> =
repository.localChannels(start, count).map { it.data }
companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ChannelsViewModel(AppGraph.repository) as T
}
}
}
@Composable
fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit,
viewModel: ChannelsViewModel = viewModel(factory = ChannelsViewModel.factory),
) {
LaunchedEffect(Unit) { viewModel.loadMore(force = true) }
val viewModel = viewModel
val items = viewModel.items
Box(Modifier.fillMaxSize()) {
when {
viewModel.loadingInitial && items.isEmpty() -> Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
items.isEmpty() -> {
val error = viewModel.initialError
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (error != null) {
Text("Could not load channels", color = MaterialTheme.colorScheme.error)
Text(error, style = MaterialTheme.typography.bodySmall)
Button(onClick = { viewModel.retryInitial() }) { Text("Retry") }
} else {
Text("Nothing here yet")
}
}
}
else -> ChannelsList(items, viewModel, onOpenChannel)
}
}
}
@Composable
private fun ChannelsList(
items: List<VideoChannelDto>,
viewModel: ChannelsViewModel,
onOpenChannel: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
itemsIndexed(items) { index, channel ->
ChannelCard(
channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
)
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() }
}
}
if (!viewModel.exhausted) {
item {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
?: (AppGraph.tokenStore.baseUrl + avatar.path)
AsyncImage(
model = url,
contentDescription = channel.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)),
)
}
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = channel.displayName.ifBlank { channel.name },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -0,0 +1,124 @@
package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Subscriptions
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.PersonalVideo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text
import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.ListSource
import net.jeena.jtubetv.ui.screens.list.VideoListScreen
enum class Destination(val title: String) {
LocalVideos("Videos"),
MyVideos("My videos"),
Channels("Channels"),
Search("Search"),
Subscriptions("Subscriptions"),
History("History"),
}
/** Home = sidebar (D-pad navigable) + content pane. */
@Composable
fun HomeScreen() {
var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
if (playingVideo != null) {
PlayerScreen(
shortUuid = playingVideo!!,
onBack = { playingVideo = null },
)
return
}
if (destination == Destination.Channels && channelDetails != null) {
VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID },
)
return
}
NavigationDrawer(drawerContent = {
NavigationDrawerColumn(
selected = destination,
onSelect = {
destination = it
channelDetails = null
},
)
}) {
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
when (destination) {
Destination.LocalVideos ->
VideoListScreen(ListSource.LocalVideos) { playingVideo = it.shortUUID }
Destination.MyVideos ->
VideoListScreen(ListSource.MyVideos) { playingVideo = it.shortUUID }
Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle },
)
Destination.Search ->
VideoListScreen(ListSource.Search) { playingVideo = it.shortUUID }
Destination.Subscriptions ->
VideoListScreen(ListSource.Subscriptions) { playingVideo = it.shortUUID }
Destination.History ->
VideoListScreen(ListSource.History) { playingVideo = it.shortUUID }
}
}
}
}
}
@Composable
private fun NavigationDrawerColumn(
selected: Destination,
onSelect: (Destination) -> Unit,
modifier: Modifier = Modifier,
) {
androidx.compose.foundation.layout.Column(modifier = modifier.width(220.dp).padding(vertical = 16.dp)) {
Text(
text = "JTube",
modifier = Modifier.padding(start = 28.dp, bottom = 16.dp),
style = androidx.tv.material3.MaterialTheme.typography.headlineSmall,
)
Destination.entries.forEach { dest ->
NavigationDrawerItem(
icon = { Icon(imageVector = dest.icon(), contentDescription = null) },
label = { Text(dest.title) },
selected = dest == selected,
onClick = { onSelect(dest) },
)
}
}
}
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector =
when (this) {
Destination.LocalVideos -> Icons.Filled.VideoLibrary
Destination.MyVideos -> Icons.Filled.PersonalVideo
Destination.Channels -> Icons.AutoMirrored.Filled.List
Destination.Search -> Icons.Filled.Search
Destination.Subscriptions -> Icons.Filled.Subscriptions
Destination.History -> Icons.Filled.History
}

View file

@ -0,0 +1,193 @@
package net.jeena.jtubetv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.api.VideoDetailDto
import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.PeerTubeRepository
sealed interface PlayerUiState {
data object Loading : PlayerUiState
data class Ready(val detail: VideoDetailDto, val playUrl: String, val isHls: Boolean) : PlayerUiState
data class Error(val message: String) : PlayerUiState
}
class PlayerViewModel(
private val repository: PeerTubeRepository,
private val tokenStore: TokenStore,
) : ViewModel() {
private val _state = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading)
val state: StateFlow<PlayerUiState> = _state
fun load(shortUuid: String) {
if (_state.value != PlayerUiState.Loading) return
viewModelScope.launch {
// Freshen the token before media requests hit it.
repository.me() // triggers refresh via OkHttp authenticator if needed
val result = repository.video(shortUuid)
result.fold(
onSuccess = { detail ->
val play = repository.playoutUrl(detail)
if (play == null) {
_state.value = PlayerUiState.Error("No playable stream found")
} else {
_state.value = PlayerUiState.Ready(detail, play.url, play.isHls)
}
},
onFailure = { e ->
_state.value = PlayerUiState.Error(e.message ?: "Could not load video")
},
)
}
}
fun reportWatching(shortUuid: String, currentTimeSeconds: Int) {
viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) }
}
companion object {
fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
PlayerViewModel(repository, tokenStore) as T
}
}
}
/**
* Fullscreen ExoPlayer. Feature 6: reports currentTime to the PeerTube server every
* ~20 s so the watch shows up in the server-side history.
*/
@Composable
fun PlayerScreen(
shortUuid: String,
onBack: () -> Unit,
viewModel: PlayerViewModel = viewModel(
key = shortUuid,
factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore),
),
) {
BackHandler { onBack() }
val state by viewModel.state.collectAsState()
DisposableEffect(shortUuid) {
viewModel.load(shortUuid)
onDispose { }
}
when (val s = state) {
is PlayerUiState.Loading -> PlayerLoading()
is PlayerUiState.Error -> PlayerError(s.message, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid)
}
}
@Composable
private fun PlayerLoading() {
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
private fun PlayerError(message: String, onBack: () -> Unit) {
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
androidx.compose.material3.Button(onClick = onBack) {
androidx.compose.material3.Text("Close ($message)")
}
}
}
@Composable
private fun PlayerSurface(
s: PlayerUiState.Ready,
viewModel: PlayerViewModel,
shortUuid: String,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val player = androidx.compose.runtime.remember {
val http = DefaultHttpDataSource.Factory()
.setAllowCrossProtocolRedirects(true)
AppGraph.tokenStore.accessToken?.let { http.setDefaultRequestProperties(mapOf("Authorization" to "Bearer $it")) }
val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, http)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSource)
ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.build()
}
DisposableEffect(s.playUrl) {
val item = MediaItem.Builder()
.setUri(s.playUrl)
.setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4)
.build()
player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
onDispose { player.release() }
}
// Periodic watching report (server-side history).
DisposableEffect(player) {
var lastReported = 0L
val runnable = object : Runnable {
override fun run() {
if (player.isPlaying) {
val now = System.currentTimeMillis()
val position = player.currentPosition
if (position >= 5_000 && now - lastReported >= 20_000) {
viewModel.reportWatching(shortUuid, (position / 1000).toInt())
lastReported = now
}
}
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000)
}
}
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(runnable, 5_000)
onDispose {
android.os.Handler(android.os.Looper.getMainLooper()).removeCallbacks(runnable)
}
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
this.player = player
controllerShowTimeoutMs = 4000
}
},
modifier = Modifier.fillMaxSize().background(Color.Black),
)
}

View file

@ -0,0 +1,102 @@
package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
/**
* Feature 0: ask for instance URL + credentials once. The password is used only for
* the token exchange and is discarded afterwards; tokens are stored encrypted.
*/
@Composable
fun SetupScreen(
loggingIn: StateFlow<Boolean>,
error: StateFlow<String?>,
onLogin: (baseUrl: String, username: String, password: String) -> Unit,
) {
val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") }
val username = rememberSaveable { mutableStateOf("") }
val password = rememberSaveable { mutableStateOf("") }
val busy = loggingIn.collectAsStateFlow()
val message = error.collectAsStateFlow()
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "JTube TV",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 24.dp),
)
Column(
modifier = Modifier.width(420.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text("PeerTube instance URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
TextField(
value = username.value,
onValueChange = { username.value = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
TextField(
value = password.value,
onValueChange = { password.value = it },
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (message.value != null) {
Text(message.value!!, color = MaterialTheme.colorScheme.error)
}
Button(
onClick = { onLogin(url.value, username.value, password.value) },
enabled = !busy.value && url.value.isNotBlank() &&
username.value.isNotBlank() && password.value.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (busy.value) {
CircularProgressIndicator(Modifier.width(20.dp))
} else {
Text("Sign in")
}
}
}
}
}
@Composable
private fun StateFlow<Boolean>.collectAsStateFlow(): State<Boolean> =
collectAsState()
@Composable
private fun StateFlow<String?>.collectAsStateFlow(): State<String?> =
collectAsState()

View file

@ -0,0 +1,145 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.api.VideoDto
import java.util.Locale
/** Horizontal card: thumbnail left, title/channel/privacy right. Focused via D-pad. */
@Composable
fun VideoCard(
video: VideoDto,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(132.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(width = 234.dp, height = 132.dp)
.background(Color(0xFF222222)),
) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
AsyncImage(
model = AppGraph.repository.absoluteUrl(path),
contentDescription = video.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 234.dp, height = 132.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
if (video.duration > 0) {
Text(
text = formatDuration(video.duration),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.background(Color(0xCC000000), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Column(
modifier = Modifier
.padding(12.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = video.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = video.channel.displayName.ifBlank { video.channel.name },
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB8B8B8),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
video.privacy?.takeIf { it.id != 1 }?.let { privacy ->
Text(
text = privacy.label ?: "non-public",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFFC6E27C),
modifier = Modifier
.background(Color(0x33C6E27C), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (video.nsfw) {
Text(
text = "NSFW",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFFE2A57C),
)
}
}
if (video.views >= 0) {
Text(
text = formatViews(video.views),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
)
}
}
}
}
}
internal fun formatDuration(seconds: Int): String {
val h = seconds / 3600
val m = (seconds % 3600) / 60
val s = seconds % 60
return when {
h > 0 -> "%d:%02d:%02d".format(Locale.US, h, m, s)
else -> "%d:%02d".format(Locale.US, m, s)
}
}
internal fun formatViews(views: Long): String =
when {
views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0)
views >= 1_000 -> "%.1fK views".format(Locale.US, views / 1_000.0)
views == 1L -> "1 view"
else -> "$views views"
}

View file

@ -0,0 +1,131 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import net.jeena.jtubetv.R
import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.ListSource
import net.jeena.jtubetv.ui.list.VideoListViewModel
@Composable
fun VideoListScreen(
source: ListSource,
onOpenVideo: (VideoDto) -> Unit,
viewModel: VideoListViewModel = viewModel(
key = sourceKey(source),
factory = VideoListViewModel.factory(source),
),
) {
val items = viewModel.items
when (source) {
is ListSource.Search -> SearchHeader(viewModel)
else -> {}
}
if (viewModel.loadingInitial && items.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
LaunchedEffect(source) { viewModel.loadMore(force = true) }
return
}
if (items.isEmpty()) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val error = viewModel.initialError
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) {
if (error != null) {
Text(stringResource(R.string.error_loading), color = MaterialTheme.colorScheme.error)
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = { viewModel.retryInitial() }) {
Text(stringResource(R.string.retry))
}
} else {
Text(stringResource(R.string.empty_list))
}
}
}
return
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
itemsIndexed(items) { index, item ->
val video = when (item) {
is VideoListViewModel.VideoListItem.HistoryEntry -> item.history.video
is VideoListViewModel.VideoListItem.Video -> item.video
}
VideoCard(
video = video,
onClick = { onOpenVideo(video) },
)
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() }
}
}
if (!viewModel.exhausted) {
item {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun SearchHeader(viewModel: VideoListViewModel) {
var text by remember { mutableStateOf(viewModel.query.value) }
Column(Modifier.fillMaxWidth().padding(16.dp)) {
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Search local videos") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.triggerSearch(text) },
modifier = Modifier.padding(top = 8.dp),
) {
Text("Search")
}
}
}
private fun sourceKey(source: ListSource): String = when (source) {
is ListSource.Channel -> "channel:${source.handle}"
else -> source::class.java.simpleName
}

View file

@ -0,0 +1,23 @@
package net.jeena.jtubetv.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val JTubeRed = Color(0xFFCE2B2B)
private val NearBlack = Color(0xFF101010)
private val CardGray = Color(0xFF1D1D1D)
private val JTubeColors = darkColorScheme(
primary = JTubeRed,
onPrimary = Color.White,
background = NearBlack,
surface = CardGray,
onSurface = Color.White,
)
@Composable
fun JTubeTvTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = JTubeColors, content = content)
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#101010"
android:pathData="M0,0h320v180h-320z" />
<path
android:fillColor="#CE2B2B"
android:pathData="M40,40h64a8,8 0 0 1 8,8v64a8,8 0 0 1 -8,8h-64a8,8 0 0 1 -8,-8v-64a8,8 0 0 1 8,-8z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M60,62l26,20l-26,20z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M128,76h160v10h-160z" />
<path
android:fillColor="#66FFFFFF"
android:pathData="M128,100h120v8h-120z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#101010"
android:pathData="M0,0h108v108h-108z" />
</vector>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30">
<path
android:fillColor="#CE2B2B"
android:pathData="M4,4h40a4,4 0 0 1 4,4v40a4,4 0 0 1 -4,4h-40a4,4 0 0 1 -4,-4v-40a4,4 0 0 1 4,-4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M20,21l13,10l-13,10z" />
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/icon_background" />
<foreground android:drawable="@drawable/icon_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<resources>
<color name="jtube_primary">#FFCE2B2B</color>
<color name="jtube_background">#FF101010</color>
</resources>

View file

@ -0,0 +1,12 @@
<resources>
<string name="app_name">JTube TV</string>
<string name="destination_videos">Videos</string>
<string name="destination_my_videos">My videos</string>
<string name="destination_channels">Channels</string>
<string name="destination_search">Search</string>
<string name="destination_subscriptions">Subscriptions</string>
<string name="destination_history">History</string>
<string name="error_loading">Something went wrong</string>
<string name="retry">Retry</string>
<string name="empty_list">Nothing here yet</string>
</resources>

View file

@ -0,0 +1,6 @@
<resources>
<style name="Theme.JTubeTV" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorPrimary">@color/jtube_primary</item>
</style>
</resources>