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

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx

86
AGENTS.md Normal file
View file

@ -0,0 +1,86 @@
# AGENTS.md — guidance for AI agents working on JTube TV
Read this before making changes. The project is an Android TV app (Kotlin, Compose
for TV) that talks to a PeerTube instance over its REST API v1.
## Environment facts
- Host is Arch Linux with Android SDK at `~/Android/Sdk` (platforms 34/35/36, build-tools).
- The Gradle wrapper is checked in (`gradlew` + `gradle/wrapper/`), so builds need only a
JDK 17+ (`java`) on PATH. If `java` is missing, tell the user — do not install things on
the host without asking.
- Useful commands:
- `./gradlew :app:assembleDebug` — build debug APK
- `./gradlew :app:lintDebug` — lint
- `adb connect <ip>:<port>` + `adb install -r app/build/outputs/apk/debug/app-debug.apk`
- This repo is local-only for now. If it gets a remote on git.jeena.net later, set the
repo-local deploy key pin right away:
`git config core.sshCommand "ssh -i ~/.ssh/agent-deploy-key -o IdentitiesOnly=yes"`
## Project layout
```
app/src/main/java/net/jeena/jtubetv/
JTubeApp.kt Application + AppGraph service locator (no DI framework)
MainActivity.kt single activity, session state → Setup or Home
data/api/PeerTubeApi.kt Retrofit endpoints
data/api/Dtos.kt kotlinx-serialization models (ignoreUnknownKeys everywhere)
data/auth/TokenStore.kt EncryptedSharedPreferences (baseUrl, username, tokens, client creds)
data/network/Network.kt Retrofit/OkHttp: BaseUrlInterceptor, AuthInterceptor,
TokenAuthenticator (single-flight refresh)
data/PeerTubeRepository.kt all suspend functions used by UI
ui/theme/Theme.kt Compose for TV theme
ui/screens/… SetupScreen, HomeScreen, VideoListScreen (modes), ChannelsScreen, PlayerScreen
ui/list/… VideoListViewModel + PagedList state helper
ui/player/…
```
State flow: `MainActivity` observes `AppViewModel.sessionState`; `NeedsSetup` renders the
setup/login screen, `Ready` renders `HomeScreen`, which switches content on a
`Destination` enum. The player pushes a video onto a state and uses a `BackHandler`.
## PeerTube API notes (verified against tube.jeena.net)
- OAuth: `GET /api/v1/oauth-clients/local` → client_id/client_secret;
`POST /api/v1/users/token` (form: grant_type=password) → access_token + refresh_token.
Access tokens expire (~24h); refresh with grant_type=refresh_token. We never persist
the user's password.
- Pagination shape: `GET …?start=&count=&sort=` → `{ total, data: [...] }`.
Sort `-publishedAt`; page size 25; infinite scroll by issuing the next page when the
list is 5 items from the end.
- Public/local videos listing, search, channels: unauthenticated endpoints work —
- `GET /api/v1/videos?isLocal=true&search=`
- `GET /api/v1/video-channels?search=…&searchTarget=local` (local channels only)
- Authenticated (`Bearer` header):
- `GET /api/v1/users/me/videos` — own videos, ALL privacy levels
- `GET /api/v1/users/me/subscriptions/videos`
- `GET /api/v1/users/me/history/videos`
- `GET /api/v1/accounts/{accountName}/video-channels`
- `POST /api/v1/videos/{shortUuid}/watching` `{ "currentTime": <seconds> }` — populates
server-side history. Report every ~20 s of wall-clock playback once currentTime ≥ 5 s.
- `GET /api/v1/videos/{shortUuid}` for playback URLs: prefer
`streamingPlaylists[].playlistUrl` (HLS) else `files[].fileUrl`.
- Private/internal media: attach `Authorization: Bearer` to playlist, segment and file
requests (configured on the Media3 `HttpDataSource.Factory`).
## Conventions
- Kotlin + coroutines; suspend functions in the repository, `runCatching` → `Result<T>`
at repository boundaries; view models expose `StateFlow`.
- No Dagger/Hilt/Room — keep the dependency surface small; revise only if it earns its keep.
- kotlinx-serialization for JSON; DTOs must use `ignoreUnknownKeys` so new PeerTube
fields never crash parsing.
- Compose for TV (`androidx.tv:tv-material`) for sidebar/cards; make list items
focusable + clickable so D-pad navigation works.
- Package prefix `net.jeena.jtubetv`. Keep files focused; don't grow `MainActivity`.
- minSdk 26 / target+compile 35; versions are pinned in `gradle/libs.versions.toml`.
## Scope discipline
v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in
uploading, editing, subscriptions management, deletion or content creation — the user
explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md`
first, then implement.
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).

43
BACKLOG.md Normal file
View file

@ -0,0 +1,43 @@
# Backlog
Status vocabulary: `todo` · `doing` · `done`.
## v0.1 — MVP (this release)
| # | Item | Status | Notes |
|---|------|--------|-------|
| 0 | Ask for credentials + instance URL, store safely | todo | Login screen → `users/token`; store tokens + OAuth client creds in EncryptedSharedPreferences, never the password; refresh-token single-flight on 401 |
| 1 | List local videos (public) + user's own videos incl. private/internal | todo | Sidebar entries "Videos" (public, `videos?isLocal=true`) and "My videos" (`users/me/videos`); privacy badge on non-public items |
| 2 | List local channels | todo | `video-channels?search=&searchTarget=local`; tap → channel videos |
| 3 | Search local videos | todo | `videos?search=…&isLocal=true` |
| 4 | Subscription videos | todo | `users/me/subscriptions/videos` |
| 5 | Watch history (server) | todo | `users/me/history/videos` read-only |
| 6 | Playback + report to server history | todo | ExoPlayer HLS (mp4 fallback); `POST videos/{shortUuid}/watching` every ~20 s |
## Next up (v0.2+)
- Unified "accessible videos" feed: merge public-local + own private into one paged,
deduplicated list (v0.1 keeps them as two sidebar entries).
- Logout / "switch account" button on the Home screen.
- Playback polish: resume from server-recorded position, next/previous video from list,
auto-focus currently watched item on history screen.
- Thumbnails of private/internal videos may 404 — probe server behavior and add a
fallback (generic poster) when required.
- Verify `searchTarget=local` channel search behaves well for instances without
federation; consider `search/video-channels` endpoint as an alternative.
- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.
- Error UX: friendlier messages (offline, instance down, wrong credentials vs. expired
token → re-login screen), retry buttons.
- i18n: move user-facing strings to `strings.xml`, consider German + English.
- Video detail screen (description, tags, views, duration, channel page link).
## Longer term
- Multiple saved instances / switching between them.
- Live streams playback (HLS live), premieres.
- Subtitles/captions selection.
- Downloads for offline watching (probably limited to public videos).
- Comments viewing (read-only) — needs moderation-safe UI thinking.
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
- Out of scope by user request (do not implement without asking): uploading, editing,
subscribing/unsubscribing, deleting, creating channels/users/playlists.

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# JTube TV
An Android TV client for [PeerTube](https://joinpeertube.org/) instances, inspired by
how SmartTube works for YouTube. It talks to **any PeerTube instance**
(the default is [JTube](https://tube.jeena.net) — my own instance) over the
[PeerTube REST API](https://docs.joinpeertube.org/api-rest-reference.html).
## Features (v0.1)
- **Setup flow** — enter instance URL, username and password once; the app exchanges
them for OAuth tokens via `POST /api/v1/users/token` and stores
**only the tokens** (never the password), encrypted with the Android Keystore
(EncryptedSharedPreferences).
- **Local videos** — list public videos of the instance (`GET /api/v1/videos?isLocal=true`).
- **My videos** — all of the logged-in user's own videos, every privacy level included
(private, internal, unlisted — `GET /api/v1/users/me/videos`). Non-public entries
carry a privacy badge.
- **Channels** — list channels local to the instance
(`GET /api/v1/video-channels?search=…&searchTarget=local`) and browse each channel's videos.
- **Search** — search local videos by title.
- **Subscriptions** — the videos in the user's subscription feed
(`GET /api/v1/users/me/subscriptions/videos`).
- **History** — the user's watch history as tracked by the server
(`GET /api/v1/users/me/history/videos`).
- **Playback** — HLS (or progressive MP4 fallback) playback with ExoPlayer/Media3.
Viewing is reported to the server (`POST /api/v1/videos/{shortUuid}/watching`) so it
shows up in server-side history. Private/internal media is streamed with the OAuth
token attached to every playlist/segment request.
Out of scope for now (see `BACKLOG.md`): uploading, editing, subscribing/unsubscribing,
deleting, creating anything other than history entries.
## Build
Requirements: JDK 17+ (JDK 21 recommended), Android SDK platform 35.
`local.properties` with `sdk.dir=…` is picked up automatically if present
(standard Android Gradle Plugin behavior).
```bash
./gradlew :app:assembleDebug
```
Install on a TV device:
```bash
adb connect <tv-ip>:<port>
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
> Building requires a JDK; the Gradle wrapper is checked in, so no local Gradle
> installation is needed.
## Architecture overview
Single-module Android TV app (`app/`):
- `data/api` — Retrofit interface + kotlinx-serialization DTOs for the PeerTube API.
- `data/auth` — `TokenStore` (EncryptedSharedPreferences), token acquisition/refresh.
- `data/network` — OkHttp/Retrofit wiring: dynamic base-URL interceptor, bearer-token
interceptor, single-flight token refresh authenticator.
- `data/PeerTubeRepository.kt` — one suspend-call surface used by all view models.
- `ui/` — Compose for TV screens: Setup, Home (sidebar + list content),
VideoList (local / my videos / subscriptions / history / search / channel),
Channels, Player. `MainActivity` is the only activity.
- `JTubeApp.kt` — tiny service locator (`AppGraph`); no DI framework on purpose.
## License
Not decided yet — TBD before first public release.

63
app/build.gradle.kts Normal file
View file

@ -0,0 +1,63 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "net.jeena.jtubetv"
compileSdk = 35
defaultConfig {
applicationId = "net.jeena.jtubetv"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.compose.icons)
implementation(libs.androidx.security.crypto)
implementation(libs.media3.exoplayer)
implementation(libs.media3.exoplayer.hls)
implementation(libs.media3.ui)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
}

17
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,17 @@
# Keep kotlinx-serialization generated serializers
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class net.jeena.jtubetv.**$$serializer { *; }
-keepclassmembers class net.jeena.jtubetv.** {
*** Companion;
}
-keepclasseswithmembers class net.jeena.jtubetv.** {
kotlinx.serialization.KSerializer serializer(...);
}

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>

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

43
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,43 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
coreKtx = "1.15.0"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
tvMaterial = "1.0.1"
media3 = "1.5.1"
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
coil = "2.7.0"
securityCrypto = "1.1.0-alpha06"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View file

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

23
settings.gradle.kts Normal file
View file

@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "jtube-tv"
include(":app")