commit 59029438ad290a1a26c52d22cbd161f7dfd4d6c6 Author: Jeena Date: Sat Oct 10 10:20:02 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0935029 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80d2623 --- /dev/null +++ b/AGENTS.md @@ -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 :` + `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": }` — 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` + 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). \ No newline at end of file diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..05484e8 --- /dev/null +++ b/BACKLOG.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ee3a32 --- /dev/null +++ b/README.md @@ -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 : +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. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7b0be9a --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1e11af --- /dev/null +++ b/app/proguard-rules.pro @@ -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(...); +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3392ea4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt new file mode 100644 index 0000000..60d5628 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt new file mode 100644 index 0000000..34e1fba --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt new file mode 100644 index 0000000..bef7c60 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -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 = + 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 = runCatching { network.api.me() } + + // ---- video lists ------------------------------------------------------- + + suspend fun localVideos(start: Long, count: Int, search: String? = null): Result = + runCatching { network.api.localVideos(start, count, search = search) } + + suspend fun myVideos(start: Long, count: Int): Result = + runCatching { network.api.myVideos(start, count) } + + suspend fun subscriptionVideos(start: Long, count: Int): Result = + runCatching { network.api.subscriptionVideos(start, count) } + + suspend fun history(start: Long, count: Int): Result> = + 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 = + runCatching { network.api.channelVideos(handle, start, count) } + + // ---- channels ---------------------------------------------------------- + + suspend fun localChannels(start: Long, count: Int, search: String? = null): Result = + runCatching { network.api.channels(start = start, count = count, search = search) } + + suspend fun accountChannels(username: String): Result> = + runCatching { + val response: VideoChannelListResponse = network.api.accountChannels(username) + response.data + } + + // ---- playback ---------------------------------------------------------- + + suspend fun video(shortUuid: String): Result = + runCatching { network.api.video(shortUuid) } + + suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = + 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) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt new file mode 100644 index 0000000..f55bc34 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -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 = 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, +) + +@Serializable +data class VideoChannelDto( + val name: String, + @SerialName("displayName") val displayName: String, + val host: String, + val description: String? = null, + val avatars: List = emptyList(), + val videosCount: Long = 0, +) + +@Serializable +data class VideoChannelListResponse( + val total: Long, + val data: List, +) + +@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 = 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 = emptyList(), + val streamingPlaylists: List = emptyList(), +) + +@Serializable +data class HistoryElement( + val video: VideoDto, + @SerialName("lastViewedAt") val lastViewedAt: String? = null, +) + +@Serializable +data class UserDto( + val username: String, +) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt new file mode 100644 index 0000000..3c9db7d --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/WatchingBody.kt b/app/src/main/java/net/jeena/jtubetv/data/api/WatchingBody.kt new file mode 100644 index 0000000..49ca89d --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/api/WatchingBody.kt @@ -0,0 +1,8 @@ +package net.jeena.jtubetv.data.api + +import kotlinx.serialization.Serializable + +@Serializable +data class WatchingBody( + val currentTime: Int, +) \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt new file mode 100644 index 0000000..9a9309e --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt new file mode 100644 index 0000000..a555d6b --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/data/network/Network.kt @@ -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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt new file mode 100644 index 0000000..4e0bae6 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -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 + private val _sessionState = MutableStateFlow(SessionState.Loading) + + val loginError = MutableStateFlow(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") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt new file mode 100644 index 0000000..043da62 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -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 : ViewModel() { + + protected val pageSize = 25 + private var inFlight = false + var exhausted by mutableStateOf(false) + private set + + var items by mutableStateOf>(emptyList()) + private set + var initialError by mutableStateOf(null) + private set + var loadingInitial by mutableStateOf(true) + private set + + protected abstract suspend fun fetch(start: Long, count: Int): Result> + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt new file mode 100644 index 0000000..162f230 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -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() { + + val query = MutableStateFlow("") + private var activeSearch: String? = null + + override suspend fun fetch(start: Long, count: Int): Result> { + 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 create(modelClass: Class): T = + VideoListViewModel(AppGraph.repository, source) as T + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt new file mode 100644 index 0000000..01c1eee --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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() { + + override suspend fun fetch(start: Long, count: Int): Result> = + repository.localChannels(start, count).map { it.data } + + companion object { + val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): 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, + 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, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..7f1fcb2 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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(null) } // handle + var playingVideo by rememberSaveable { mutableStateOf(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 + } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..979bce2 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -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.Loading) + val state: StateFlow = _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 create(modelClass: Class): 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), + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt new file mode 100644 index 0000000..0da3e79 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -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, + error: StateFlow, + 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.collectAsStateFlow(): State = + collectAsState() + +@Composable +private fun StateFlow.collectAsStateFlow(): State = + collectAsState() \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt new file mode 100644 index 0000000..75fc84c --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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" + } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt new file mode 100644 index 0000000..a122511 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/theme/Theme.kt b/app/src/main/java/net/jeena/jtubetv/ui/theme/Theme.kt new file mode 100644 index 0000000..585e44a --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/theme/Theme.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml new file mode 100644 index 0000000..29ba394 --- /dev/null +++ b/app/src/main/res/drawable/app_banner.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_background.xml b/app/src/main/res/drawable/icon_background.xml new file mode 100644 index 0000000..3027bb8 --- /dev/null +++ b/app/src/main/res/drawable/icon_background.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_foreground.xml b/app/src/main/res/drawable/icon_foreground.xml new file mode 100644 index 0000000..74dc88e --- /dev/null +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d20a6e6 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..709e209 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + #FFCE2B2B + #FF101010 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..135e505 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + JTube TV + Videos + My videos + Channels + Search + Subscriptions + History + Something went wrong + Retry + Nothing here yet + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..13cfa67 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4fd2c76 --- /dev/null +++ b/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b77813e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..df24d3b --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8fc91c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..d95bf61 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9935b5c --- /dev/null +++ b/settings.gradle.kts @@ -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") \ No newline at end of file