Without a mouse the channel banner was invisible: opening a channel focused the first video card, which scrolled the header (a grid item) off-screen. The header is now pinned above the grid and never scrolls away. The banner is its backdrop, darkened to black at the bottom; the avatar sits beside the title with a "by <owner> on <platform>" byline, the subscriber/video/created stats, and the description flowing into the black beneath (a long one grows the header down instead of being clipped). A Subscribe button sits on the banner, reachable with UP from the first card, wired to the same follow/unfollow as the player. AGENTS.md: subscribing is in scope (already on the player, now the channel page too), so drop it from the excluded list.
123 lines
No EOL
7.2 KiB
Markdown
123 lines
No EOL
7.2 KiB
Markdown
# 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. The Android SDK the build actually uses is `/opt/android-sdk`
|
|
(`ANDROID_SDK_ROOT` — note `local.properties`' `sdk.dir` points to the outdated
|
|
`~/Android/Sdk`, which doesn't exist; platforms 34/35/36, build-tools).
|
|
- Emulator testing (host emulator + agent container over adb): see
|
|
`docs/agent-container.md` — covers start script, ports, and what to reinstall
|
|
after a container rebuild.
|
|
- The Gradle wrapper is checked in (`gradlew` + `gradle/wrapper/`), so builds need only a
|
|
JDK 17+ (`java`) on PATH. If `java` is missing, tell the user — do not install things on
|
|
the host without asking.
|
|
- Useful commands:
|
|
- `./gradlew :app:assembleDebug` — build debug APK
|
|
- `./gradlew :app:lintDebug` — lint
|
|
- `adb connect <ip>:<port>` + `adb install -r app/build/outputs/apk/debug/app-debug.apk`
|
|
- This repo is local-only for now. If it gets a remote on git.jeena.net later, set the
|
|
repo-local deploy key pin right away:
|
|
`git config core.sshCommand "ssh -i ~/.ssh/agent-deploy-key -o IdentitiesOnly=yes"`
|
|
|
|
## Project layout
|
|
|
|
```
|
|
app/src/main/java/net/jeena/jtubetv/
|
|
JTubeApp.kt Application + AppGraph service locator (no DI framework)
|
|
MainActivity.kt single activity, session state → Setup or Home
|
|
data/api/PeerTubeApi.kt Retrofit endpoints
|
|
data/api/Dtos.kt kotlinx-serialization models (ignoreUnknownKeys everywhere)
|
|
data/auth/TokenStore.kt EncryptedSharedPreferences (baseUrl, username, tokens, client creds)
|
|
data/network/Network.kt Retrofit/OkHttp: BaseUrlInterceptor, AuthInterceptor,
|
|
TokenAuthenticator (single-flight refresh)
|
|
data/PeerTubeRepository.kt all suspend functions used by UI
|
|
ui/theme/Theme.kt Compose for TV theme
|
|
ui/screens/… SetupScreen, HomeScreen, VideoListScreen (modes), ChannelsScreen, PlayerScreen
|
|
ui/list/… VideoListViewModel + PagedList state helper
|
|
ui/player/…
|
|
```
|
|
|
|
State flow: `MainActivity` observes `AppViewModel.sessionState`; `NeedsSetup` renders the
|
|
setup/login screen, `Ready` renders `HomeScreen`, which switches content on a
|
|
`Destination` enum. The player pushes a video onto a state and uses a `BackHandler`.
|
|
|
|
## PeerTube API notes (verified 2026-10-10 against tube.jeena.net, v8.3.1)
|
|
|
|
- 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` and `users/me/history/videos` return
|
|
the standard `{ total, data: [...] }` wrapper (verified live); history items are
|
|
plain video objects — **`lastViewedAt` is NOT returned** (don't rely on it)
|
|
- `GET /api/v1/accounts/{accountName}/video-channels`
|
|
- `POST /api/v1/videos/{shortUuid}/watching` JSON body `{ "currentTime": <seconds> }`
|
|
→ HTTP 204 (verified). This is what populates server-side history AND the stored
|
|
playback position. Report every ~20 s of wall-clock playback once currentTime ≥ 5 s.
|
|
- Resume-from-server possible via `GET /api/v1/videos/{shortUuid}?userHistory=true`
|
|
→ `userHistory: { currentTime, currentPercent }` (verified)
|
|
- `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`) AND — for instances
|
|
whose media lives in a private object-storage bucket — pass the short-lived
|
|
`POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs
|
|
(response shape: `{ files: { token, expires } }`; verified live).
|
|
- Some instances stream non-public media from object-storage hosts with only
|
|
URL-based access control — never rely on that; always request and use the
|
|
file token for non-public videos. (Instance-specific findings stay out of
|
|
the public repo and live in the private ops notes/agent-local instructions.)
|
|
|
|
## Issue workflow (contract, 2026-10-14)
|
|
|
|
Forgejo issues at git.jeena.net/jeena/jtube-tv are Jeena's acceptance queue —
|
|
the workflow is strict:
|
|
|
|
1. **Only Jeena closes issues.** The agent never closes anything. Every issue is
|
|
a change Jeena tests on real TV hardware before giving the OK.
|
|
2. **Commit messages reference issues without closing keywords.** "Closes",
|
|
"fixes", "resolves" and their variants auto-close issues when pushed —
|
|
never use them. Reference by plain number or "Addresses #N", and mention the
|
|
*why* in the body so the review stands on its own.
|
|
3. **An implemented issue is not done** until Jeena's OK on the TV. State in the
|
|
commit or final message which build to install to test it.
|
|
4. Agent testing on the emulator/TV via adb is a precondition, not a substitute
|
|
for Jeena's check.
|
|
5. If a fix turned out to land in a different commit than the one that mentions
|
|
the issue, name the fixing commit explicitly in the message body so review
|
|
can trace it. (A bad example that must not repeat: bundling "Closes #21"
|
|
into an unrelated pushed commit — the commit-to-issue link looked wrong.)
|
|
|
|
## Conventions
|
|
|
|
- Kotlin + coroutines; suspend functions in the repository, `runCatching` → `Result<T>`
|
|
at repository boundaries; view models expose `StateFlow`.
|
|
- No Dagger/Hilt/Room — keep the dependency surface small; revise only if it earns its keep.
|
|
- kotlinx-serialization for JSON; DTOs must use `ignoreUnknownKeys` so new PeerTube
|
|
fields never crash parsing.
|
|
- Compose for TV (`androidx.tv:tv-material`) for sidebar/cards; make list items
|
|
focusable + clickable so D-pad navigation works.
|
|
- Package prefix `net.jeena.jtubetv`. Keep files focused; don't grow `MainActivity`.
|
|
- minSdk 26 / target+compile 35; versions are pinned in `gradle/libs.versions.toml`.
|
|
|
|
## Scope discipline
|
|
|
|
Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
|
|
git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
|
|
creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
|
|
in scope and already implemented — on the player and the channel page.) If the user asks
|
|
for a scope change, confirm it with them before implementing.
|
|
|
|
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). |