From 764fa8b34cad47e28cd8f356aa4341e0c66da1f9 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 10:53:49 +0000 Subject: [PATCH 01/53] docs: Record verified watching and history API behavior Live-tested the the test account account against tube.jeena.net: the watching endpoint stores the playback position (resume possible via userHistory=true) and populates user history; history rows carry no lastViewedAt. Notes feed the v0.2 resume-from-server item. --- AGENTS.md | 14 +++++++++----- BACKLOG.md | 12 ++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80d2623..0068e61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ State flow: `MainActivity` observes `AppViewModel.sessionState`; `NeedsSetup` re 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) +## 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. @@ -53,11 +53,15 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a - `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/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` `{ "currentTime": }` — populates - server-side history. Report every ~20 s of wall-clock playback once currentTime ≥ 5 s. + - `POST /api/v1/videos/{shortUuid}/watching` JSON body `{ "currentTime": }` + → 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 diff --git a/BACKLOG.md b/BACKLOG.md index 05484e8..b8d95ef 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -19,13 +19,21 @@ Status vocabulary: `todo` · `doing` · `done`. - 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. +- Playback polish: resume from server-recorded position via + `GET videos/{shortUuid}?userHistory=true` (verified working, returns + `userHistory.currentTime`), next/previous video from list, focus current item in + 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. +- Verify `searchTarget=local` channel search behaves well for instances without + federation; consider `search/video-channels` endpoint as an alternative. +- (Verified 2026-10-10 via the test account account) history subscriptions + watching endpoints + behave as documented; upload seeding uses **flat multipart form fields** on + `/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for + seeding test data, the app itself doesn't upload. - 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. From 3eff3724c8b78261be720a9bbeb815dec978bafa Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 10:53:49 +0000 Subject: [PATCH 02/53] docs: Record verified watching and history API behavior Live-tested the tvbot account against tube.jeena.net: the watching endpoint stores the playback position (resume possible via userHistory=true) and populates user history; history rows carry no lastViewedAt. Notes feed the v0.2 resume-from-server item. --- AGENTS.md | 14 +++++++++----- BACKLOG.md | 12 ++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80d2623..0068e61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ State flow: `MainActivity` observes `AppViewModel.sessionState`; `NeedsSetup` re 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) +## 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. @@ -53,11 +53,15 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a - `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/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` `{ "currentTime": }` — populates - server-side history. Report every ~20 s of wall-clock playback once currentTime ≥ 5 s. + - `POST /api/v1/videos/{shortUuid}/watching` JSON body `{ "currentTime": }` + → 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 diff --git a/BACKLOG.md b/BACKLOG.md index 05484e8..8286d9b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -19,13 +19,21 @@ Status vocabulary: `todo` · `doing` · `done`. - 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. +- Playback polish: resume from server-recorded position via + `GET videos/{shortUuid}?userHistory=true` (verified working, returns + `userHistory.currentTime`), next/previous video from list, focus current item in + 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. +- Verify `searchTarget=local` channel search behaves well for instances without + federation; consider `search/video-channels` endpoint as an alternative. +- (Verified 2026-10-10 via tvbot account) history subscriptions + watching endpoints + behave as documented; upload seeding uses **flat multipart form fields** on + `/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for + seeding test data, the app itself doesn't upload. - 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. From 83dde8ba74fe9ec9c41ef387d6e14ef14848eb59 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 11:02:22 +0000 Subject: [PATCH 03/53] test: Add e2e release gate (script + manual checklist) Releases need more than eyeballs: scripts/e2e-api-check.sh now runs 18 assertions over the live instance (login/refresh, listings, private rows, subscriptions incl. remote channels, watching + stored position, file token, playlist reachability) and TESTS.md defines the manual D-pad checkout that a script cannot do. Also switch the channels screen to the user's own channels (the search-based local listing rejects empty queries) and resolve the real account name at login since logins accept email addresses. --- AGENTS.md | 8 +- BACKLOG.md | 8 +- TESTS.md | 95 +++++++++++++++ .../jeena/jtubetv/data/PeerTubeRepository.kt | 14 +++ .../java/net/jeena/jtubetv/data/api/Dtos.kt | 11 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 6 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 5 + .../jtubetv/ui/screens/ChannelsScreen.kt | 20 +++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 13 +- scripts/e2e-api-check.sh | 112 ++++++++++++++++++ 10 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 TESTS.md create mode 100755 scripts/e2e-api-check.sh diff --git a/AGENTS.md b/AGENTS.md index 0068e61..1e68716 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,13 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a - `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`). + 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). + - tube.jeena.net itself streams non-public media from a **publicly readable R2 + bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on + that — always request the file token for non-public videos. ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index b8d95ef..ddd3ea7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,8 +23,12 @@ Status vocabulary: `todo` · `doing` · `done`. `GET videos/{shortUuid}?userHistory=true` (verified working, returns `userHistory.currentTime`), next/previous video from list, focus current item in history screen. -- Thumbnails of private/internal videos may 404 — probe server behavior and add a - fallback (generic poster) when required. +- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: + instance thumbnail paths are publicly readable (HTTP 200 without auth); add a + fallback poster only if other instances behave differently. +- Security note for tube.jeena.net: object-storage (R2) media for private test + videos is publicly readable without token ("private" only via URL obscurity). + Flag to the instance admin; the app must not rely on this laxness. - 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. diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 0000000..5d79d5a --- /dev/null +++ b/TESTS.md @@ -0,0 +1,95 @@ +# E2E tests — release gate + +How to validate a release. Two layers: + +1. **Scripted API smoke test** — `scripts/e2e-api-check.sh`, runs anywhere sops+jtube.env + are available (container or any agent machine). Fast feedback on the backend contract. +2. **Manual device test** — on an Android TV with a remote (D-pad!). Compose TV focus + behavior can only be validated by hand; use the checklist below. + +The manual checklist is a hard gate for releases; the script alone is not enough even +when everything passes (playback, focus and UI states are untestable by script). + +## Test environment / prerequisites + +- Instance: any PeerTube (baseline: , v8.3.1, media on R2 object storage). +- Account: `the test account` (sops: `~/.agents/secrets/jtube.env`). Credentials resolved + at login; **do not store the password in the device during tests** — it's typed once + on the setup screen (which is exactly feature 0). +- Seeded content (tagged `jtube-tv-test`, channel `the test channel`): + - `JTube TV Test 1 - Sintel Teaser` **Private** (~52 s) + - `JTube TV Test 2 - Caminandes Llamigos` **Private** (~2:30) + - `JTube TV Test 3 - Cosmos Laundromat` **Private** (~50 s) + - the test account has watched entries in server history (public + test videos). +- Subscription: `mediterranean_homestead@lone.earth` (remote channel — exercises + federation in the subscription feed). +- Public content: instance's own local videos (≥ 3000). +- Device: Android TV with D-pad; also sanity-check a phone profile for the touch path. +- Network conditions if available: Wi-Fi + (later) metered/lossy playback test. + +## A. Scripted API checks (run before every release) + +```bash +./scripts/e2e-api-check.sh # exit 0 = backend contract intact +``` + +Covers: login/refresh, local videos, my-videos incl. private rows, own channels, +local channel search, search, subscription feed (incl. remote channel), history shape, +watching POST → 204 → stored position, stream URL resolution, file-token endpoint, +playlist reachability. + +## B. Manual device checklist + +Legend: [T] must pass for TV (D-pad). Pass criteria named per item; log failures with +APK version + device. + +### 0 — Credentials onboarding +- [ ] T1 Fresh install: no URL/account stored → setup screen appears with URL prefilled to `https://tube.jeena.net`. +- [ ] T2 Login with wrong password → app shows "Invalid credentials", user stays on setup, nothing persisted (clear app data → setup shows again). Repeat once to catch double-submit lockout via spinner + disabled button. +- [ ] T3 Login with bad host (`x.invalid`) → reachable-error message, no crash. +- [ ] T4 Successful login → home screen; force-stop app, relaunch → home screen directly (session persisted, encrypted). Tokens survive (no re-login). +- [ ] T5 Long-run: 24 h later (token expiry ~86 400 s) relaunch → any authorized screen still works without re-login (refresh-token flow ran silently). If this can't be waited out, simulate by `adb shell` clearing only the access token? — skip; instead validate refresh via the API script (F0) and mark T5 after one real 24 h exposure per release cycle. + +### 1 — Local videos / My videos +- [ ] T6 "Videos" entry: list loads, newest public local videos first, thumbnails visible, D-pad scroll down past 25 rows loads page 2 (spinner at bottom disappears, list grows). +- [ ] T7 "My videos": the 3 seeded **Private** test videos appear with a privacy badge; non-test public videos of the account also appear if any. +- [ ] T8 Scroll from "Videos" to "My videos" and back → each screen keeps its own list (no cross-contamination of cached pages). +- [ ] T9 Long video title truncates (2 lines max) without breaking the row layout; long channel names truncate. + +### 2 — Channels +- [ ] T10 "Channels" lists account's own channels (`the test channel`), avatar when present, video count. +- [ ] T11 Click channel → that channel's videos list; open and play one; back returns to the channel's list (not the channels screen lost state). +- [ ] T12 Remote channel via direct handle is out of MVP scope, but local-channel search (when implemented) must be re-tested: `searchTarget=local` ignores federated results. + +### 3 — Search +- [ ] T13 Search screen: field focusable via D-pad; on-screen/IME input works. Search "Sintel" → Test 1 private video appears even though it's private (search endpoint filters locally: verify it does — if private videos are NOT in search results, this is expected PeerTube behavior; adjust expectation: my-videos screen is the place private content is discoverable). +- [ ] T14 Empty search string → no crash, stays on empty state. Gibberish → "Nothing here yet". + +### 4 — Subscriptions +- [ ] T15 Subscriptions feed shows the remote (`lone.earth`) channel's videos — mixed-feed behavior works; newest first. +- [ ] T16 Feed pagination works beyond 25 items when subscribed to enough channels (skip if < 50 items total; note skip reason). +- [ ] T17 Click remote video → plays or fails *gracefully* (a remote video's media is on the remote host; MVP may fail to resolve — must show error toast/message + back, never crash/freeze). + +### 5 — History +- [ ] T18 History lists server-side history newest-first, incl. both public and private watched entries. +- [ ] T19 Play an entry to ≥ 20 s, leave (back) → entry is present/updated in history when re-opening the screen (server round-trip within ~1 s; app must not show stale cache as fresh — reload on screen entry). + +### 6 — Playback +- [ ] T20 Play a **public local** video → video+audio start < 5 s, D-pad pauses/plays, seek bar appears via center press, back closes player and lands on the list. +- [ ] T21 Play a **private** test video → plays. (On tube.jeena.net media is publicly readable today; the app must still send the file token — verify in logs that `?videoFileToken=` was appended, since that is what private-bucket instances need.) +- [ ] T22 Long watch: stay in a video > 60 s (watching reports every ~20 s) → server `userHistory.currentTime` updated; re-entering the video later resumes or at least proves the reports landed (curl check). +- [ ] T23 Switch between two videos (from list, play, back, play another) → no audio ghosting/duplicate players (only one active ExoPlayer; release resources on exit). +- [ ] T24 Airplane-mode / dead WiFi: opening player → error state shown, no crash; back works. +- [ ] T25 Remote-hosted video (subscription item, media on lone.earth) → if it plays it must not break when network switches; at minimum no crash. +- [ ] T26 Video without HLS (progressive-only instance): force by playing an item whose detail has no `streamingPlaylists` — if none exists on the test instance, note skip. + +### 7 — Robustness / release hygiene +- [ ] T27 App data cleared while logged out → setup screen, no crash loops. +- [ ] T28 Rotate/screen-size changes are irrelevant on TV (landscape only), but app must not crash on phones when sideloaded. +- [ ] T29 Kill server (or wrong URL after instance rename): any authorized screen → consistent error UI + retry; stale-token case must eventually surface "re-login" (authenticator gives up). +- [ ] T30 APK is signed, minified build installs and boots (release build, `minifyEnabled` once enabled — re-run the whole checklist for it, R8 has bitten before). + +## Release gate + +All [T] items pass on the release build; script exits 0. Skips must be documented with +reason. New features added in a release must append their checklist items to section B. \ 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 index bef7c60..ec2ffb3 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -67,6 +67,12 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) + // Logins accept emails; resolve the real account name for /accounts/{handle}. + try { + store.accountName = api.me().username + } catch (_: Exception) { + store.accountName = username // best effort fallback + } } suspend fun me(): Result = runCatching { network.api.me() } @@ -110,6 +116,14 @@ class PeerTubeRepository( suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } + /** + * Short-lived URL token for non-public media. On instances storing media in a + * private object-storage bucket, playlist/segment URLs only accept this token as + * `?videoFileToken=`; the Bearer header only works for instance-served files. + */ + suspend fun videoToken(shortUuid: String): Result = + runCatching { network.api.videoToken(shortUuid).files.token } + // ---- media URLs -------------------------------------------------------- /** 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 index f55bc34..f335486 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -115,6 +115,17 @@ data class HistoryElement( @SerialName("lastViewedAt") val lastViewedAt: String? = null, ) +@Serializable +data class VideoTokenResponse( + val files: VideoTokenFiles, +) + +@Serializable +data class VideoTokenFiles( + val token: String, + val expires: String? = null, +) + @Serializable data class UserDto( val username: String, 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 index 8c29964..5b24be4 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -103,6 +103,12 @@ interface PeerTubeApi { @Body body: WatchingBody, ) + /** Short-lived token for private/internal media URLs (object storage safe). */ + @POST("videos/{shortUuid}/token") + suspend fun videoToken( + @Path("shortUuid") shortUuid: String, + ): VideoTokenResponse + @GET("users/me") suspend fun me(): UserDto } \ 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 index 9a9309e..2620641 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -48,6 +48,11 @@ class TokenStore(context: Context) { get() = prefs.getString("refreshToken", null) private set(value) = prefs.edit().putString("refreshToken", value).apply() + /** Real account name from users/me — may differ from the login input (email). */ + var accountName: String? + get() = prefs.getString("accountName", null) + set(value) = prefs.edit().putString("accountName", value).apply() + var tokenExpiresAtMillis: Long get() = prefs.getLong("tokenExpiresAtMillis", 0L) private set(value) = prefs.edit().putLong("tokenExpiresAtMillis", value).apply() 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 index 6a7ef4f..afe4a03 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -32,20 +32,32 @@ 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.auth.TokenStore import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.list.PagedListViewModel -class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { +class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel() { + + private var cached: List? = null override suspend fun fetch(start: Long, count: Int): Result> = - repository.localChannels(start, count).map { it.data } + runCatching { + val existing = cached + if (existing == null) { + // MVP: the user's own channels (fully local). A paged, searchable + // local-channels listing needs a non-empty search term server-side. + val response = AppGraph.repository.accountChannels( + store.accountName ?: store.username ?: "", + ) + response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) + } else existing + } companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - ChannelsViewModel(AppGraph.repository) as T + ChannelsViewModel(AppGraph.tokenStore) as T } } } 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 index 979bce2..5976dc9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -60,7 +60,18 @@ class PlayerViewModel( if (play == null) { _state.value = PlayerUiState.Error("No playable stream found") } else { - _state.value = PlayerUiState.Ready(detail, play.url, play.isHls) + // Non-public media may need a short-lived file token instead of + // (or in addition to) the Bearer header — matters on instances + // whose object-storage bucket rejects anonymous requests. + var url = play.url + if (detail.privacy?.id != 1) { + repository.videoToken(detail.shortUUID).onSuccess { token -> + url += (if (url.contains('?')) "&" else "?") + + "videoFileToken=" + + java.net.URLEncoder.encode(token, "UTF-8") + } + } + _state.value = PlayerUiState.Ready(detail, url, play.isHls) } }, onFailure = { e -> diff --git a/scripts/e2e-api-check.sh b/scripts/e2e-api-check.sh new file mode 100755 index 0000000..f5c53fb --- /dev/null +++ b/scripts/e2e-api-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# e2e-api-check.sh — scripted end-to-end smoke test against a live PeerTube instance. +# +# Usage: ./e2e-api-check.sh (uses ~/.agents/secrets/jtube.env via sops) +# sops -d .env | bash <(cat e2e-api-check.sh) — alternative pipe form +# +# Requires: curl, python3. The account may be read-only-ish; only /watching POSTs and +# history reads are needed besides the listings. Prints PASS/FAIL per group; exits +# non-zero if any FAIL. +set -u + +pass=0; fail=0 +ok() { pass=$((pass+1)); echo "PASS $1"; } +bad() { fail=$((fail+1)); echo "FAIL $1"; } +assert() { # assert + if [ "${2:-x}" = "true" ]; then ok "$1"; else bad "$1"; fi +} + +# ---- credentials ----------------------------------------------------------- +if [ "${JTUBE_URL:-}" = "" ]; then + if command -v sops >/dev/null && [ -f "$HOME/.agents/secrets/jtube.env" ]; then + eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + else + echo "No credentials: set JTUBE_URL/JTUBE_USER/JTUBE_PASS or provide the store"; exit 2 + fi +fi + +# ---- login ----------------------------------------------------------------- +CLIENT=$(curl -s "$JTUBE_URL/api/v1/oauth-clients/local") +CID=$(echo "$CLIENT" | python3 -c "import json,sys;print(json.load(sys.stdin)['client_id'])") +CSEC=$(echo "$CLIENT" | python3 -c "import json,sys;print(json.load(sys.stdin)['client_secret'])") +TOKJSON=$(curl -s -X POST "$JTUBE_URL/api/v1/users/token" \ + -d "grant_type=password" -d "client_id=$CID" -d "client_secret=$CSEC" \ + -d "username=$JTUBE_USER" -d "password=$JTUBE_PASS") +TOK=$(echo "$TOKJSON" | python3 -c "import json,sys;print(json.load(sys.stdin).get('access_token',''))") +RTOK=$(echo "$TOKJSON" | python3 -c "import json,sys;print(json.load(sys.stdin).get('refresh_token',''))") +assert "F0 login (password grant)" $([ -n "$TOK" ] && [ -n "$RTOK" ] && echo true || echo false) + +AUTH="Authorization: Bearer $TOK" +jqp() { python3 -c "import json,sys;d=json.load(sys.stdin);import functools;print(eval('d'+sys.argv[1]))" "$1" 2>/dev/null; } + +# ---- F0b: refresh flow ----------------------------------------------------- +NEW=$(curl -s -X POST "$JTUBE_URL/api/v1/users/token" \ + -d "grant_type=refresh_token" -d "client_id=$CID" -d "client_secret=$CSEC" -d "refresh_token=$RTOK" \ + | jqp "['access_token']") +assert "F0 token refresh" $([ -n "$NEW" ] && echo true || echo false) +TOK="$NEW"; AUTH="Authorization: Bearer $TOK" + +# ---- F1 public local videos ------------------------------------------------ +L=$(curl -s "$JTUBE_URL/api/v1/videos?isLocal=true&count=10&sort=-publishedAt") +N=$(echo "$L" | jqp "['total']") +assert "F1 local videos total>0" $([ "${N:-0}" -gt 0 ] && echo true || echo false) +FIRST_UUID=$(echo "$L" | jqp "['data'][0]['shortUUID']") + +# ---- F1 me/videos incl. private ------------------------------------------- +M=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/videos?count=25&sort=-publishedAt") +assert "F1 my-videos wrapped {total,data}" $(echo "$M" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d and isinstance(d.get('data'),list) else 'false')") +assert "F1 my-videos has entries" $(echo "$M" | jqp "['total'] > 0 and 'true' or 'false'") +PRIV=$(echo "$M" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print('true' if any(v['privacy']['id']!=1 for v in d['data']) else 'false')") +assert "F1 non-public entries present (seeded test videos)" "$PRIV" + +# ---- F2 own channels ------------------------------------------------------- +USERNAME=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me" | jqp "['username']") +AC=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/accounts/$USERNAME/video-channels") +assert "F2 own channels listed" $(echo "$AC" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") +CN=$(echo "$AC" | python3 -c "import json,sys; d=json.load(sys.stdin)['data'][0]; print(d['name']+'@'+d['host'])") +CV=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/video-channels/$CN/videos?count=3") +assert "F2 channel videos endpoint" $(echo "$CV" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") +LC=$(curl -s "$JTUBE_URL/api/v1/video-channels?search=tube&searchTarget=local&count=5") +assert "F2 local-channel search (non-empty q)" $(echo "$LC" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") + +# ---- F3 search -------------------------------------------------------------- +S=$(curl -s "$JTUBE_URL/api/v1/videos?search=$FIRST_UUID&isLocal=true&count=5") +S2=$(curl -s "$JTUBE_URL/api/v1/videos?search=test&isLocal=true&count=5") +assert "F3 search returns list shape" $(echo "$S2" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") + +# ---- F4 subscriptions ------------------------------------------------------- +SUBS=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/subscriptions/videos?count=10&sort=-publishedAt") +assert "F4 subscription feed wrapped" $(echo "$SUBS" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") +SUBS_REMOTE=$(echo "$SUBS" | python3 -c " +import json,sys,os +d=json.load(sys.stdin) +host=os.environ['JTUBE_URL'].replace('https://','').rstrip('/') +print('true' if any(v['channel']['host']!=host for v in d['data']) else 'false')") +assert "F4 remote-channel entries present" "$SUBS_REMOTE" + +# ---- F5+F6 history & watching ---------------------------------------------- +H=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/history/videos?count=10") +assert "F5 history wrapped" $(echo "$H" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"currentTime":3}' "$JTUBE_URL/api/v1/videos/$FIRST_UUID/watching") +assert "F6 watching -> 204" $([ "$CODE" = "204" ] && echo true || echo false) +UH=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID?userHistory=true" | jqp "['userHistory']['currentTime']") +assert "F6 watching stored (userHistory)" $( [ -n "$UH" ] && echo true || echo false ) + +# ---- playback URLs ---------------------------------------------------------- +D=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID") +PL=$(echo "$D" | jqp "['streamingPlaylists'][0]['playlistUrl']") +assert "F6 detail exposes stream URL" $([ -n "$PL" ] && echo true || echo false) +VFTCODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID/token") +assert "F6 file token endpoint exists" $([ "$VFTCODE" = "200" ] && echo true || echo false) +VFT=$(curl -s -X POST -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID/token" | jqp "['files']['token']") +HC=$(curl -s -o /dev/null -w "%{http_code}" "$PL?videoFileToken=$VFT") +assert "F6 playlist reachable" $([ "$HC" = "200" ] && echo true || echo false) + +echo "-----------------------------" +echo "PASS=$pass FAIL=$fail" +[ "$fail" = 0 ] \ No newline at end of file From 3733167061a0371956a0bc8e2a62e0ec14df8578 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 11:02:22 +0000 Subject: [PATCH 04/53] test: Add e2e release gate (script + manual checklist) Releases need more than eyeballs: scripts/e2e-api-check.sh now runs 18 assertions over the live instance (login/refresh, listings, private rows, subscriptions incl. remote channels, watching + stored position, file token, playlist reachability) and TESTS.md defines the manual D-pad checkout that a script cannot do. Also switch the channels screen to the user's own channels (the search-based local listing rejects empty queries) and resolve the real account name at login since logins accept email addresses. --- AGENTS.md | 8 +- BACKLOG.md | 8 +- TESTS.md | 95 +++++++++++++++ .../jeena/jtubetv/data/PeerTubeRepository.kt | 14 +++ .../java/net/jeena/jtubetv/data/api/Dtos.kt | 11 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 6 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 5 + .../jtubetv/ui/screens/ChannelsScreen.kt | 20 +++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 13 +- scripts/e2e-api-check.sh | 112 ++++++++++++++++++ 10 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 TESTS.md create mode 100755 scripts/e2e-api-check.sh diff --git a/AGENTS.md b/AGENTS.md index 0068e61..1e68716 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,13 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a - `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`). + 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). + - tube.jeena.net itself streams non-public media from a **publicly readable R2 + bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on + that — always request the file token for non-public videos. ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 8286d9b..4395782 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,8 +23,12 @@ Status vocabulary: `todo` · `doing` · `done`. `GET videos/{shortUuid}?userHistory=true` (verified working, returns `userHistory.currentTime`), next/previous video from list, focus current item in history screen. -- Thumbnails of private/internal videos may 404 — probe server behavior and add a - fallback (generic poster) when required. +- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: + instance thumbnail paths are publicly readable (HTTP 200 without auth); add a + fallback poster only if other instances behave differently. +- Security note for tube.jeena.net: object-storage (R2) media for private test + videos is publicly readable without token ("private" only via URL obscurity). + Flag to the instance admin; the app must not rely on this laxness. - 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. diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 0000000..80e22f6 --- /dev/null +++ b/TESTS.md @@ -0,0 +1,95 @@ +# E2E tests — release gate + +How to validate a release. Two layers: + +1. **Scripted API smoke test** — `scripts/e2e-api-check.sh`, runs anywhere sops+jtube.env + are available (container or any agent machine). Fast feedback on the backend contract. +2. **Manual device test** — on an Android TV with a remote (D-pad!). Compose TV focus + behavior can only be validated by hand; use the checklist below. + +The manual checklist is a hard gate for releases; the script alone is not enough even +when everything passes (playback, focus and UI states are untestable by script). + +## Test environment / prerequisites + +- Instance: any PeerTube (baseline: , v8.3.1, media on R2 object storage). +- Account: `tvbot@jeena.net` (sops: `~/.agents/secrets/jtube.env`). Credentials resolved + at login; **do not store the password in the device during tests** — it's typed once + on the setup screen (which is exactly feature 0). +- Seeded content (tagged `jtube-tv-test`, channel `tvbots`): + - `JTube TV Test 1 - Sintel Teaser` **Private** (~52 s) + - `JTube TV Test 2 - Caminandes Llamigos` **Private** (~2:30) + - `JTube TV Test 3 - Cosmos Laundromat` **Private** (~50 s) + - tvbot has watched entries in server history (public + test videos). +- Subscription: `mediterranean_homestead@lone.earth` (remote channel — exercises + federation in the subscription feed). +- Public content: instance's own local videos (≥ 3000). +- Device: Android TV with D-pad; also sanity-check a phone profile for the touch path. +- Network conditions if available: Wi-Fi + (later) metered/lossy playback test. + +## A. Scripted API checks (run before every release) + +```bash +./scripts/e2e-api-check.sh # exit 0 = backend contract intact +``` + +Covers: login/refresh, local videos, my-videos incl. private rows, own channels, +local channel search, search, subscription feed (incl. remote channel), history shape, +watching POST → 204 → stored position, stream URL resolution, file-token endpoint, +playlist reachability. + +## B. Manual device checklist + +Legend: [T] must pass for TV (D-pad). Pass criteria named per item; log failures with +APK version + device. + +### 0 — Credentials onboarding +- [ ] T1 Fresh install: no URL/account stored → setup screen appears with URL prefilled to `https://tube.jeena.net`. +- [ ] T2 Login with wrong password → app shows "Invalid credentials", user stays on setup, nothing persisted (clear app data → setup shows again). Repeat once to catch double-submit lockout via spinner + disabled button. +- [ ] T3 Login with bad host (`x.invalid`) → reachable-error message, no crash. +- [ ] T4 Successful login → home screen; force-stop app, relaunch → home screen directly (session persisted, encrypted). Tokens survive (no re-login). +- [ ] T5 Long-run: 24 h later (token expiry ~86 400 s) relaunch → any authorized screen still works without re-login (refresh-token flow ran silently). If this can't be waited out, simulate by `adb shell` clearing only the access token? — skip; instead validate refresh via the API script (F0) and mark T5 after one real 24 h exposure per release cycle. + +### 1 — Local videos / My videos +- [ ] T6 "Videos" entry: list loads, newest public local videos first, thumbnails visible, D-pad scroll down past 25 rows loads page 2 (spinner at bottom disappears, list grows). +- [ ] T7 "My videos": the 3 seeded **Private** test videos appear with a privacy badge; non-test public videos of the account also appear if any. +- [ ] T8 Scroll from "Videos" to "My videos" and back → each screen keeps its own list (no cross-contamination of cached pages). +- [ ] T9 Long video title truncates (2 lines max) without breaking the row layout; long channel names truncate. + +### 2 — Channels +- [ ] T10 "Channels" lists account's own channels (`tvbots`), avatar when present, video count. +- [ ] T11 Click channel → that channel's videos list; open and play one; back returns to the channel's list (not the channels screen lost state). +- [ ] T12 Remote channel via direct handle is out of MVP scope, but local-channel search (when implemented) must be re-tested: `searchTarget=local` ignores federated results. + +### 3 — Search +- [ ] T13 Search screen: field focusable via D-pad; on-screen/IME input works. Search "Sintel" → Test 1 private video appears even though it's private (search endpoint filters locally: verify it does — if private videos are NOT in search results, this is expected PeerTube behavior; adjust expectation: my-videos screen is the place private content is discoverable). +- [ ] T14 Empty search string → no crash, stays on empty state. Gibberish → "Nothing here yet". + +### 4 — Subscriptions +- [ ] T15 Subscriptions feed shows the remote (`lone.earth`) channel's videos — mixed-feed behavior works; newest first. +- [ ] T16 Feed pagination works beyond 25 items when subscribed to enough channels (skip if < 50 items total; note skip reason). +- [ ] T17 Click remote video → plays or fails *gracefully* (a remote video's media is on the remote host; MVP may fail to resolve — must show error toast/message + back, never crash/freeze). + +### 5 — History +- [ ] T18 History lists server-side history newest-first, incl. both public and private watched entries. +- [ ] T19 Play an entry to ≥ 20 s, leave (back) → entry is present/updated in history when re-opening the screen (server round-trip within ~1 s; app must not show stale cache as fresh — reload on screen entry). + +### 6 — Playback +- [ ] T20 Play a **public local** video → video+audio start < 5 s, D-pad pauses/plays, seek bar appears via center press, back closes player and lands on the list. +- [ ] T21 Play a **private** test video → plays. (On tube.jeena.net media is publicly readable today; the app must still send the file token — verify in logs that `?videoFileToken=` was appended, since that is what private-bucket instances need.) +- [ ] T22 Long watch: stay in a video > 60 s (watching reports every ~20 s) → server `userHistory.currentTime` updated; re-entering the video later resumes or at least proves the reports landed (curl check). +- [ ] T23 Switch between two videos (from list, play, back, play another) → no audio ghosting/duplicate players (only one active ExoPlayer; release resources on exit). +- [ ] T24 Airplane-mode / dead WiFi: opening player → error state shown, no crash; back works. +- [ ] T25 Remote-hosted video (subscription item, media on lone.earth) → if it plays it must not break when network switches; at minimum no crash. +- [ ] T26 Video without HLS (progressive-only instance): force by playing an item whose detail has no `streamingPlaylists` — if none exists on the test instance, note skip. + +### 7 — Robustness / release hygiene +- [ ] T27 App data cleared while logged out → setup screen, no crash loops. +- [ ] T28 Rotate/screen-size changes are irrelevant on TV (landscape only), but app must not crash on phones when sideloaded. +- [ ] T29 Kill server (or wrong URL after instance rename): any authorized screen → consistent error UI + retry; stale-token case must eventually surface "re-login" (authenticator gives up). +- [ ] T30 APK is signed, minified build installs and boots (release build, `minifyEnabled` once enabled — re-run the whole checklist for it, R8 has bitten before). + +## Release gate + +All [T] items pass on the release build; script exits 0. Skips must be documented with +reason. New features added in a release must append their checklist items to section B. \ 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 index bef7c60..ec2ffb3 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -67,6 +67,12 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) + // Logins accept emails; resolve the real account name for /accounts/{handle}. + try { + store.accountName = api.me().username + } catch (_: Exception) { + store.accountName = username // best effort fallback + } } suspend fun me(): Result = runCatching { network.api.me() } @@ -110,6 +116,14 @@ class PeerTubeRepository( suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } + /** + * Short-lived URL token for non-public media. On instances storing media in a + * private object-storage bucket, playlist/segment URLs only accept this token as + * `?videoFileToken=`; the Bearer header only works for instance-served files. + */ + suspend fun videoToken(shortUuid: String): Result = + runCatching { network.api.videoToken(shortUuid).files.token } + // ---- media URLs -------------------------------------------------------- /** 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 index f55bc34..f335486 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -115,6 +115,17 @@ data class HistoryElement( @SerialName("lastViewedAt") val lastViewedAt: String? = null, ) +@Serializable +data class VideoTokenResponse( + val files: VideoTokenFiles, +) + +@Serializable +data class VideoTokenFiles( + val token: String, + val expires: String? = null, +) + @Serializable data class UserDto( val username: String, 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 index 8c29964..5b24be4 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -103,6 +103,12 @@ interface PeerTubeApi { @Body body: WatchingBody, ) + /** Short-lived token for private/internal media URLs (object storage safe). */ + @POST("videos/{shortUuid}/token") + suspend fun videoToken( + @Path("shortUuid") shortUuid: String, + ): VideoTokenResponse + @GET("users/me") suspend fun me(): UserDto } \ 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 index 9a9309e..2620641 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -48,6 +48,11 @@ class TokenStore(context: Context) { get() = prefs.getString("refreshToken", null) private set(value) = prefs.edit().putString("refreshToken", value).apply() + /** Real account name from users/me — may differ from the login input (email). */ + var accountName: String? + get() = prefs.getString("accountName", null) + set(value) = prefs.edit().putString("accountName", value).apply() + var tokenExpiresAtMillis: Long get() = prefs.getLong("tokenExpiresAtMillis", 0L) private set(value) = prefs.edit().putLong("tokenExpiresAtMillis", value).apply() 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 index 6a7ef4f..afe4a03 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -32,20 +32,32 @@ 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.auth.TokenStore import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.list.PagedListViewModel -class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { +class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel() { + + private var cached: List? = null override suspend fun fetch(start: Long, count: Int): Result> = - repository.localChannels(start, count).map { it.data } + runCatching { + val existing = cached + if (existing == null) { + // MVP: the user's own channels (fully local). A paged, searchable + // local-channels listing needs a non-empty search term server-side. + val response = AppGraph.repository.accountChannels( + store.accountName ?: store.username ?: "", + ) + response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) + } else existing + } companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - ChannelsViewModel(AppGraph.repository) as T + ChannelsViewModel(AppGraph.tokenStore) as T } } } 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 index 979bce2..5976dc9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -60,7 +60,18 @@ class PlayerViewModel( if (play == null) { _state.value = PlayerUiState.Error("No playable stream found") } else { - _state.value = PlayerUiState.Ready(detail, play.url, play.isHls) + // Non-public media may need a short-lived file token instead of + // (or in addition to) the Bearer header — matters on instances + // whose object-storage bucket rejects anonymous requests. + var url = play.url + if (detail.privacy?.id != 1) { + repository.videoToken(detail.shortUUID).onSuccess { token -> + url += (if (url.contains('?')) "&" else "?") + + "videoFileToken=" + + java.net.URLEncoder.encode(token, "UTF-8") + } + } + _state.value = PlayerUiState.Ready(detail, url, play.isHls) } }, onFailure = { e -> diff --git a/scripts/e2e-api-check.sh b/scripts/e2e-api-check.sh new file mode 100755 index 0000000..f5c53fb --- /dev/null +++ b/scripts/e2e-api-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# e2e-api-check.sh — scripted end-to-end smoke test against a live PeerTube instance. +# +# Usage: ./e2e-api-check.sh (uses ~/.agents/secrets/jtube.env via sops) +# sops -d .env | bash <(cat e2e-api-check.sh) — alternative pipe form +# +# Requires: curl, python3. The account may be read-only-ish; only /watching POSTs and +# history reads are needed besides the listings. Prints PASS/FAIL per group; exits +# non-zero if any FAIL. +set -u + +pass=0; fail=0 +ok() { pass=$((pass+1)); echo "PASS $1"; } +bad() { fail=$((fail+1)); echo "FAIL $1"; } +assert() { # assert + if [ "${2:-x}" = "true" ]; then ok "$1"; else bad "$1"; fi +} + +# ---- credentials ----------------------------------------------------------- +if [ "${JTUBE_URL:-}" = "" ]; then + if command -v sops >/dev/null && [ -f "$HOME/.agents/secrets/jtube.env" ]; then + eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + else + echo "No credentials: set JTUBE_URL/JTUBE_USER/JTUBE_PASS or provide the store"; exit 2 + fi +fi + +# ---- login ----------------------------------------------------------------- +CLIENT=$(curl -s "$JTUBE_URL/api/v1/oauth-clients/local") +CID=$(echo "$CLIENT" | python3 -c "import json,sys;print(json.load(sys.stdin)['client_id'])") +CSEC=$(echo "$CLIENT" | python3 -c "import json,sys;print(json.load(sys.stdin)['client_secret'])") +TOKJSON=$(curl -s -X POST "$JTUBE_URL/api/v1/users/token" \ + -d "grant_type=password" -d "client_id=$CID" -d "client_secret=$CSEC" \ + -d "username=$JTUBE_USER" -d "password=$JTUBE_PASS") +TOK=$(echo "$TOKJSON" | python3 -c "import json,sys;print(json.load(sys.stdin).get('access_token',''))") +RTOK=$(echo "$TOKJSON" | python3 -c "import json,sys;print(json.load(sys.stdin).get('refresh_token',''))") +assert "F0 login (password grant)" $([ -n "$TOK" ] && [ -n "$RTOK" ] && echo true || echo false) + +AUTH="Authorization: Bearer $TOK" +jqp() { python3 -c "import json,sys;d=json.load(sys.stdin);import functools;print(eval('d'+sys.argv[1]))" "$1" 2>/dev/null; } + +# ---- F0b: refresh flow ----------------------------------------------------- +NEW=$(curl -s -X POST "$JTUBE_URL/api/v1/users/token" \ + -d "grant_type=refresh_token" -d "client_id=$CID" -d "client_secret=$CSEC" -d "refresh_token=$RTOK" \ + | jqp "['access_token']") +assert "F0 token refresh" $([ -n "$NEW" ] && echo true || echo false) +TOK="$NEW"; AUTH="Authorization: Bearer $TOK" + +# ---- F1 public local videos ------------------------------------------------ +L=$(curl -s "$JTUBE_URL/api/v1/videos?isLocal=true&count=10&sort=-publishedAt") +N=$(echo "$L" | jqp "['total']") +assert "F1 local videos total>0" $([ "${N:-0}" -gt 0 ] && echo true || echo false) +FIRST_UUID=$(echo "$L" | jqp "['data'][0]['shortUUID']") + +# ---- F1 me/videos incl. private ------------------------------------------- +M=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/videos?count=25&sort=-publishedAt") +assert "F1 my-videos wrapped {total,data}" $(echo "$M" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d and isinstance(d.get('data'),list) else 'false')") +assert "F1 my-videos has entries" $(echo "$M" | jqp "['total'] > 0 and 'true' or 'false'") +PRIV=$(echo "$M" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print('true' if any(v['privacy']['id']!=1 for v in d['data']) else 'false')") +assert "F1 non-public entries present (seeded test videos)" "$PRIV" + +# ---- F2 own channels ------------------------------------------------------- +USERNAME=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me" | jqp "['username']") +AC=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/accounts/$USERNAME/video-channels") +assert "F2 own channels listed" $(echo "$AC" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") +CN=$(echo "$AC" | python3 -c "import json,sys; d=json.load(sys.stdin)['data'][0]; print(d['name']+'@'+d['host'])") +CV=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/video-channels/$CN/videos?count=3") +assert "F2 channel videos endpoint" $(echo "$CV" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") +LC=$(curl -s "$JTUBE_URL/api/v1/video-channels?search=tube&searchTarget=local&count=5") +assert "F2 local-channel search (non-empty q)" $(echo "$LC" | python3 -c "import json,sys;print('true' if 'total' in json.load(sys.stdin) else 'false')") + +# ---- F3 search -------------------------------------------------------------- +S=$(curl -s "$JTUBE_URL/api/v1/videos?search=$FIRST_UUID&isLocal=true&count=5") +S2=$(curl -s "$JTUBE_URL/api/v1/videos?search=test&isLocal=true&count=5") +assert "F3 search returns list shape" $(echo "$S2" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") + +# ---- F4 subscriptions ------------------------------------------------------- +SUBS=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/subscriptions/videos?count=10&sort=-publishedAt") +assert "F4 subscription feed wrapped" $(echo "$SUBS" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") +SUBS_REMOTE=$(echo "$SUBS" | python3 -c " +import json,sys,os +d=json.load(sys.stdin) +host=os.environ['JTUBE_URL'].replace('https://','').rstrip('/') +print('true' if any(v['channel']['host']!=host for v in d['data']) else 'false')") +assert "F4 remote-channel entries present" "$SUBS_REMOTE" + +# ---- F5+F6 history & watching ---------------------------------------------- +H=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/users/me/history/videos?count=10") +assert "F5 history wrapped" $(echo "$H" | python3 -c "import json,sys;d=json.load(sys.stdin);print('true' if 'total' in d else 'false')") +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"currentTime":3}' "$JTUBE_URL/api/v1/videos/$FIRST_UUID/watching") +assert "F6 watching -> 204" $([ "$CODE" = "204" ] && echo true || echo false) +UH=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID?userHistory=true" | jqp "['userHistory']['currentTime']") +assert "F6 watching stored (userHistory)" $( [ -n "$UH" ] && echo true || echo false ) + +# ---- playback URLs ---------------------------------------------------------- +D=$(curl -s -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID") +PL=$(echo "$D" | jqp "['streamingPlaylists'][0]['playlistUrl']") +assert "F6 detail exposes stream URL" $([ -n "$PL" ] && echo true || echo false) +VFTCODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID/token") +assert "F6 file token endpoint exists" $([ "$VFTCODE" = "200" ] && echo true || echo false) +VFT=$(curl -s -X POST -H "$AUTH" "$JTUBE_URL/api/v1/videos/$FIRST_UUID/token" | jqp "['files']['token']") +HC=$(curl -s -o /dev/null -w "%{http_code}" "$PL?videoFileToken=$VFT") +assert "F6 playlist reachable" $([ "$HC" = "200" ] && echo true || echo false) + +echo "-----------------------------" +echo "PASS=$pass FAIL=$fail" +[ "$fail" = 0 ] \ No newline at end of file From 43056065e136779c11c4dd6cc3d6715725a64a30 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 11:29:08 +0000 Subject: [PATCH 05/53] ui: SmartTube-style home rows, focus memory and UX-review fixes The home screen is now stacked horizontal rows ('Continue watching' with red progress bars from server-reported positions, 'New videos' paged) instead of one tall list - matching the SmartTube look the app is modeled on. The player renders as an overlay so the list beneath keeps its scroll and focus across BACK (was resetting to the top), grid scroll state is saved per destination, cards render focus scale/border via tv-material, thumbnails carry relative dates and privacy badges only where meaningful, overscan margins guard the 1080p safe area, and the setup screen got a password reveal, clipboard paste helpers and an always-focusable validating Sign in. UX review by Claude Opus 5.5 (claude CLI) on emulator screenshots; remaining suggestions are triaged in BACKLOG.md. --- BACKLOG.md | 43 ++-- app/build.gradle.kts | 1 + .../java/net/jeena/jtubetv/MainActivity.kt | 21 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 7 +- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 6 + .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 1 + .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 10 +- .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 195 ++++++++++++++++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 122 ++++++----- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 4 + .../jeena/jtubetv/ui/screens/SetupScreen.kt | 63 +++++- .../jtubetv/ui/screens/list/VideoCard.kt | 174 ++++++++++------ .../ui/screens/list/VideoListScreen.kt | 27 ++- 14 files changed, 532 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt diff --git a/BACKLOG.md b/BACKLOG.md index ddd3ea7..947bade 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,27 +2,42 @@ Status vocabulary: `todo` · `doing` · `done`. -## v0.1 — MVP (this release) +## v0.1 — MVP (implemented, pending device sign-off) | # | 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 | +| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation | +| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too | +| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 | +| 3 | Search local videos | done | Own screen in sidebar | +| 4 | Subscription videos | done | Verified incl. remote (federated) channels | +| 5 | Watch history (server) | done | read-only grid | +| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | ## Next up (v0.2+) +UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll +memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus +(tv-material Cards with scale/border), relative dates on cards, privacy badge only on +My videos, password reveal + paste, overscan margins, sign-out entry, neutral form +focus colors, always-focusable Sign in with inline validation. Deferred from review: + +- "Continue watching" refinement: auto-remove finished entries, exact resume (start + playback at `userHistory.currentTime`, show "Resuming at 1:23" toast). +- Trending/featured row on Home; rows per subscribed channel (SmartTube-like). +- Video progress bars in ALL lists (needs per-video history merge; currently only + Continue-watching has them). +- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed; + auto-next after video end; subtitles (captions) support; phone-QR-assisted login. +- Drawer restructure proposal: Home / Channels (subs first) / Search / History / + Account — decide together with merged-feed item below. - 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 via - `GET videos/{shortUuid}?userHistory=true` (verified working, returns - `userHistory.currentTime`), next/previous video from list, focus current item in - history screen. + deduplicated list (v0.1 keeps Home rows + My videos). +- Global local-channels browse (search endpoint rejects empty queries) — consider + `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`. +- Logout / switch account: sign-out entry exists; add switch-account (re-login screen + without losing scroll state). +- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. - Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: instance thumbnail paths are publicly readable (HTTP 200 without auth); add a fallback poster only if other instances behave differently. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b0be9a..8778cad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt index 34e1fba..669b5bf 100644 --- a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt +++ b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt @@ -3,6 +3,7 @@ package net.jeena.jtubetv import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator @@ -12,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import net.jeena.jtubetv.BuildConfig import androidx.lifecycle.viewmodel.compose.viewModel import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.SessionState @@ -22,16 +24,31 @@ import net.jeena.jtubetv.ui.theme.JTubeTvTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val viewModel: AppViewModel by viewModels() + seedFromIntent(viewModel) setContent { JTubeTvTheme { - AppRoot() + AppRoot(viewModel) } } } + + /** + * Debug-only automation hook: `am start ... --es jtube_url ... --es jtube_user ... + * --es jtube_pass ...` performs the login without IME gymnastics. Never available + * in release builds. + */ + private fun seedFromIntent(viewModel: AppViewModel) { + if (!BuildConfig.DEBUG) return + val url = intent.getStringExtra("jtube_url") ?: return + val user = intent.getStringExtra("jtube_user") ?: return + val pass = intent.getStringExtra("jtube_pass") ?: return + viewModel.login(url, user, pass) + } } @Composable -private fun AppRoot(viewModel: AppViewModel = viewModel()) { +private fun AppRoot(viewModel: AppViewModel) { val session by viewModel.sessionState.collectAsState() Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) { diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index ec2ffb3..cee4fe0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -110,8 +110,8 @@ class PeerTubeRepository( // ---- playback ---------------------------------------------------------- - suspend fun video(shortUuid: String): Result = - runCatching { network.api.video(shortUuid) } + suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result = + runCatching { network.api.video(shortUuid, userHistory = userHistory) } suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } @@ -146,6 +146,9 @@ class PeerTubeRepository( fun hasSession(): Boolean = store.hasSession() + /** Full logout (feature request from UX review): forget URL, username and tokens. */ + fun signOut() = store.clearAll() + class BadCredentialsException : Exception("Invalid credentials") private fun plainOkHttp(): OkHttpClient = 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 index f335486..52a1de0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -91,6 +91,11 @@ data class StreamingPlaylist( val files: List = emptyList(), ) +@Serializable +data class UserHistoryDto( + val currentTime: Int? = null, +) + @Serializable data class VideoDetailDto( val id: Long, @@ -107,6 +112,7 @@ data class VideoDetailDto( val channel: VideoChannelSummary, val files: List = emptyList(), val streamingPlaylists: List = emptyList(), + val userHistory: UserHistoryDto? = null, ) @Serializable 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 index 5b24be4..76dd8e2 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -95,6 +95,7 @@ interface PeerTubeApi { @GET("videos/{shortUuid}") suspend fun video( @Path("shortUuid") shortUuid: String, + @Query("userHistory") userHistory: Boolean? = null, ): VideoDetailDto @POST("videos/{shortUuid}/watching") diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 03721c3..b615684 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -30,9 +30,9 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit fun login(baseUrl: String, username: String, password: String) { viewModelScope.launch { loggingIn.value = true - loginError.value = null + loginError.value = null val result = repository.login(baseUrl, username, password) - loggingIn.value = false + loggingIn.value = false if (result.isSuccess) { _sessionState.value = SessionState.Ready } else { @@ -44,4 +44,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit } } } + + /** Clears the stored session; the user returns to the setup screen. */ + fun logout() { + repository.signOut() + _sessionState.value = SessionState.NeedsSetup + } } \ 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 index afe4a03..c0a4d3f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -132,6 +132,7 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), + colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { val avatar = channel.avatars.maxByOrNull { it.width } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt new file mode 100644 index 0000000..91d4d8a --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -0,0 +1,195 @@ +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Text as TvText +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.PeerTubeRepository +import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.list.VideoListViewModel +import net.jeena.jtubetv.ui.screens.list.VideoCard + +private const val ROW_CARD_WIDTH = 296 + +/** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ +data class ContinueItem(val video: VideoDto, val progress: Float?) + +class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { + + var continueItems by mutableStateOf>(emptyList()) + private set + var continueLoading by mutableStateOf(true) + private set + private var loaded = false + + val recent = RecentVideosViewModel(repository) + + /** History's newest entries + their resume positions from video detail */ + fun load() { + if (loaded) return + loaded = true + viewModelScope.launch { + val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } + if (history == null) { + continueLoading = false + return@launch + } + // resume info needs one detail call with ?userHistory=true per entry; + // keep it small - only the first six videos. + val items = history.take(6).map { entry -> + async { + val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() + val watched = detail?.userHistory?.currentTime ?: 0 + val progress = if (detail != null && detail.duration > 0 && watched > 0) { + (watched.toFloat() / detail.duration).coerceIn(0.001f, 0.99f) + } else null + ContinueItem(entry.video, progress) + } + }.awaitAll() + continueItems = items + continueLoading = false + } + } + + companion object { + val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + HomeRowsViewModel(AppGraph.repository) as T + } + } +} + +/** Paged "New videos" row content. */ +class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + override suspend fun fetch(start: Long, count: Int): Result> = + repository.localVideos(start, count).map { it.data } + + companion object { + fun factory(repo: PeerTubeRepository): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RecentVideosViewModel(repo) as T + } + } +} + +/** + * SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress + * bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally. + */ +@Composable +fun HomeRowsScreen( + onOpenVideo: (VideoDto) -> Unit, + viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), +) { + LaunchedEffect(Unit) { viewModel.load() } + val recentItems = viewModel.recent.items + + Column(modifier = Modifier.fillMaxSize()) { + if (viewModel.continueItems.isNotEmpty()) { + VideoRow( + header = "Continue watching", + videos = viewModel.continueItems.map { it.video }, + progressOf = { shortUUID -> + viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress + }, + onOpenVideo = onOpenVideo, + // already limited to 6; no paging + onNearEnd = { }, + ) + } + VideoRow( + header = "New videos", + videos = recentItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onNearEnd = { viewModel.recent.loadMore() }, + ) + } +} + +@Composable +private fun VideoRow( + header: String, + videos: List, + progressOf: (String) -> Float?, + onOpenVideo: (VideoDto) -> Unit, + onNearEnd: () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + TvText( + text = header, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + ) + if (videos.isEmpty()) { + LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows + Box( + Modifier + .fillMaxWidth() + .height(64.dp), + contentAlignment = Alignment.CenterStart, + ) { + TvText( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + modifier = Modifier.padding(start = 16.dp), + ) + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(videos) { index, video -> + VideoCard( + video = video, + onClick = { onOpenVideo(video) }, + modifier = Modifier.width(ROW_CARD_WIDTH.dp), + showPrivacy = false, + progress = progressOf(video.shortUUID), + ) + if (index >= videos.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { onNearEnd() } + } + } + } + } + } + } +} \ 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 index 537bbe1..900a3f7 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -8,6 +8,7 @@ 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.Logout import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.VideoLibrary @@ -24,11 +25,13 @@ 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.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import androidx.activity.compose.BackHandler enum class Destination(val title: String) { - LocalVideos("Videos"), + Home("Home"), MyVideos("My videos"), Channels("Channels"), Search("Search"), @@ -36,61 +39,76 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. */ +/** Home = sidebar (D-pad navigable) + content pane. The player renders as a + * fullscreen overlay: the list beneath stays composed, so its scroll position and + * focused card survive BACK (couch users don't want to re-navigate from the top). */ @Composable -fun HomeScreen() { - var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) } +fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { + var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID - if (playingVideo != null) { - PlayerScreen( - shortUuid = playingVideo!!, - onBack = { playingVideo = null }, - ) - return + // Channel drilldown hides the sidebar; remote BACK must return to the channels list. + BackHandler(enabled = playingVideo == null && channelDetails != null) { + channelDetails = null } - if (destination == Destination.Channels && channelDetails != null) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - ) - return - } - - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, + Box(modifier = Modifier.fillMaxSize()) { + if (destination == Destination.Channels && channelDetails != null) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins (list content, not the player). + .padding(horizontal = 32.dp), + ) { + VideoListScreen( + source = ListSource.Channel(channelDetails!!, channelDetails!!), + onOpenVideo = { playingVideo = it.shortUUID }, ) } - } - }) { - Row(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (destination) { - Destination.LocalVideos -> - VideoListScreen( - source = ListSource.LocalVideos, - onOpenVideo = { playingVideo = it.shortUUID }, + } else { + NavigationDrawer(drawerContent = { + androidx.compose.foundation.layout.Column( + modifier = Modifier.width(220.dp).padding(vertical = 16.dp), + ) { + Text( + text = "JTube TV", + modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), + style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, + color = androidx.compose.ui.graphics.Color.White, + ) + Destination.entries.forEach { dest -> + NavigationDrawerItem( + selected = dest == destination, + onClick = { + destination = dest + channelDetails = null + }, + leadingContent = { + Icon(imageVector = dest.icon(), contentDescription = null) + }, + content = { Text(dest.title) }, ) + } + NavigationDrawerItem( + selected = false, + onClick = { viewModel.logout() }, + leadingContent = { + Icon(imageVector = Icons.Filled.Logout, contentDescription = null) + }, + content = { Text("Sign out") }, + ) + } + }) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 16.dp), + ) { + when (destination) { + Destination.Home -> + HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, @@ -115,14 +133,22 @@ fun HomeScreen() { onOpenVideo = { playingVideo = it.shortUUID }, ) } + } } } + + if (playingVideo != null) { + PlayerScreen( + shortUuid = playingVideo!!, + onBack = { playingVideo = null }, + ) + } } } private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = when (this) { - Destination.LocalVideos -> Icons.Filled.VideoLibrary + Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo Destination.Channels -> Icons.AutoMirrored.Filled.List Destination.Search -> Icons.Filled.Search 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 index 5976dc9..b3f8cbf 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -177,10 +177,14 @@ private fun PlayerSurface( if (player.isPlaying) { val now = System.currentTimeMillis() val position = player.currentPosition + android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms") if (position >= 5_000 && now - lastReported >= 20_000) { + android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s") viewModel.reportWatching(shortUuid, (position / 1000).toInt()) lastReported = now } + } else { + android.util.Log.d("JTubeWatch", "tick not playing") } android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) } 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 index 52e15bb..b0f2614 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -18,6 +19,7 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @@ -34,8 +36,11 @@ fun SetupScreen( val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") } val username = rememberSaveable { mutableStateOf("") } val password = rememberSaveable { mutableStateOf("") } + val showPassword = rememberSaveable { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current val busy = loggingIn.collectAsState() val message = error.collectAsState() + val localError = rememberSaveable { mutableStateOf(null) } Column( modifier = Modifier @@ -59,6 +64,7 @@ fun SetupScreen( label = { Text("PeerTube instance URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = username.value, @@ -66,21 +72,60 @@ fun SetupScreen( label = { Text("Username") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = password.value, onValueChange = { password.value = it }, label = { Text("Password") }, singleLine = true, + visualTransformation = + if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) - if (message.value != null) { - Text(message.value!!, color = MaterialTheme.colorScheme.error) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.TextButton(onClick = { showPassword.value = !showPassword.value }) { + Text(if (showPassword.value) "Hide password" else "Show password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + password.value = it + } + }, + ) { + Text("Paste password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + username.value = it + } + }, + ) { + Text("Paste username") + } + } + if (message.value != null || localError.value != null) { + Text(message.value ?: localError.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(), + // Deliberately always focusable: on TV a permanently disabled button + // is unreachable and leaves the user stuck without feedback (UX review). + // Validation happens on press with an inline message instead. + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + enabled = !busy.value, modifier = Modifier.fillMaxWidth(), ) { if (busy.value) { @@ -92,3 +137,11 @@ fun SetupScreen( } } } + +/** Neutral focus colors; the red primary reads as "error" on form fields (UX review). */ +@Composable +private fun fieldColors() = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedBorderColor = androidx.compose.ui.graphics.Color.White, + focusedLabelColor = androidx.compose.ui.graphics.Color.White, + unfocusedLabelColor = androidx.compose.ui.graphics.Color(0xFFB8B8B8), +) 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 index 75fc84c..5eaa1c1 100644 --- 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 @@ -8,11 +8,7 @@ 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 @@ -23,80 +19,110 @@ 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.tv.material3.Card +import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto +import java.time.Duration +import java.time.Instant +import java.time.format.DateTimeParseException import java.util.Locale -/** Horizontal card: thumbnail left, title/channel/privacy right. Focused via D-pad. */ +/** + * Grid card: 16:9 thumbnail on top, title/meta underneath. tv-material Card gives the + * D-pad focus treatment (scale + 3dp border) for free. + * + * [showPrivacy] is on for "My videos" only — on a personal instance the Internal + * badge on every card is noise (UX review finding). + */ @Composable fun VideoCard( video: VideoDto, onClick: () -> Unit, modifier: Modifier = Modifier, + showPrivacy: Boolean = true, + progress: Float? = null, ) { 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), + .height(268.dp) + .padding(6.dp), + colors = CardDefaults.colors(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, + Box( + modifier = Modifier + .fillMaxWidth() + .height(158.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 + .fillMaxWidth() + .height(158.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), + ) + } + progress?.takeIf { it in 0.001f..0.999f }?.let { fraction -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.BottomStart) + .background(Color(0x55FFFFFF)), + ) { + Box( 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), + .fillMaxWidth(fraction) + .height(4.dp) + .background(Color(0xFFCE2B2B)), ) } } - Column( - modifier = Modifier - .padding(12.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + } + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = video.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = video.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, + text = video.channel.displayName.ifBlank { video.channel.name }, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFB8B8B8), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - 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 -> + video.privacy?.takeIf { it.id != 1 }?.let { privacy -> + if (showPrivacy) { Text( text = privacy.label ?: "non-public", style = MaterialTheme.typography.labelSmall, @@ -106,22 +132,21 @@ fun VideoCard( .padding(horizontal = 6.dp, vertical = 2.dp), ) } - if (video.nsfw) { - Text( - text = "NSFW", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFFE2A57C), - ) - } } - if (video.views >= 0) { + if (video.nsfw) { Text( - text = formatViews(video.views), - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + text = "NSFW", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFE2A57C), ) } } + Text( + text = formatAge(video.publishedAt) + + if (video.views > 0) " · " + formatViews(video.views) else "", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + ) } } } @@ -142,4 +167,21 @@ internal fun formatViews(views: Long): String = 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 + } + +internal fun formatAge(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return try { + val age = Duration.between(Instant.parse(iso), Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now") + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } + } catch (_: DateTimeParseException) { + "" + } +} \ 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 index b6f278f..3bb45e0 100644 --- 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 @@ -5,8 +5,9 @@ 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.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -14,12 +15,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +44,15 @@ fun VideoListScreen( ), ) { val items = viewModel.items + // Pull initial focus out of the sidebar rail and into the content grid. + val initialFocus = remember { FocusRequester() } + LaunchedEffect(items.isNotEmpty(), source) { + if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + } + // Scroll position survives destination switches (per source), not just player exits. + val gridState = androidx.compose.runtime.saveable.rememberSaveable( + key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + ) { androidx.compose.foundation.lazy.grid.LazyGridState() } when (source) { is ListSource.Search -> SearchHeader(viewModel) @@ -82,8 +95,12 @@ fun VideoListScreen( return } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, item -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + ) { + itemsIndexed(items) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.Video -> item.video @@ -92,6 +109,8 @@ fun VideoListScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, + showPrivacy = source is ListSource.MyVideos, + modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } From aa36301ed796f7a42545b46f51cf2fa8d957a6f5 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 11:29:08 +0000 Subject: [PATCH 06/53] ui: SmartTube-style home rows, focus memory and UX-review fixes The home screen is now stacked horizontal rows ('Continue watching' with red progress bars from server-reported positions, 'New videos' paged) instead of one tall list - matching the SmartTube look the app is modeled on. The player renders as an overlay so the list beneath keeps its scroll and focus across BACK (was resetting to the top), grid scroll state is saved per destination, cards render focus scale/border via tv-material, thumbnails carry relative dates and privacy badges only where meaningful, overscan margins guard the 1080p safe area, and the setup screen got a password reveal, clipboard paste helpers and an always-focusable validating Sign in. UX review by Claude Opus 5.5 (claude CLI) on emulator screenshots; remaining suggestions are triaged in BACKLOG.md. --- BACKLOG.md | 43 ++-- app/build.gradle.kts | 1 + .../java/net/jeena/jtubetv/MainActivity.kt | 21 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 7 +- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 6 + .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 1 + .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 10 +- .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 195 ++++++++++++++++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 122 ++++++----- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 4 + .../jeena/jtubetv/ui/screens/SetupScreen.kt | 63 +++++- .../jtubetv/ui/screens/list/VideoCard.kt | 174 ++++++++++------ .../ui/screens/list/VideoListScreen.kt | 27 ++- 14 files changed, 532 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt diff --git a/BACKLOG.md b/BACKLOG.md index 4395782..08cad82 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,27 +2,42 @@ Status vocabulary: `todo` · `doing` · `done`. -## v0.1 — MVP (this release) +## v0.1 — MVP (implemented, pending device sign-off) | # | 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 | +| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation | +| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too | +| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 | +| 3 | Search local videos | done | Own screen in sidebar | +| 4 | Subscription videos | done | Verified incl. remote (federated) channels | +| 5 | Watch history (server) | done | read-only grid | +| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | ## Next up (v0.2+) +UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll +memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus +(tv-material Cards with scale/border), relative dates on cards, privacy badge only on +My videos, password reveal + paste, overscan margins, sign-out entry, neutral form +focus colors, always-focusable Sign in with inline validation. Deferred from review: + +- "Continue watching" refinement: auto-remove finished entries, exact resume (start + playback at `userHistory.currentTime`, show "Resuming at 1:23" toast). +- Trending/featured row on Home; rows per subscribed channel (SmartTube-like). +- Video progress bars in ALL lists (needs per-video history merge; currently only + Continue-watching has them). +- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed; + auto-next after video end; subtitles (captions) support; phone-QR-assisted login. +- Drawer restructure proposal: Home / Channels (subs first) / Search / History / + Account — decide together with merged-feed item below. - 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 via - `GET videos/{shortUuid}?userHistory=true` (verified working, returns - `userHistory.currentTime`), next/previous video from list, focus current item in - history screen. + deduplicated list (v0.1 keeps Home rows + My videos). +- Global local-channels browse (search endpoint rejects empty queries) — consider + `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`. +- Logout / switch account: sign-out entry exists; add switch-account (re-login screen + without losing scroll state). +- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. - Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: instance thumbnail paths are publicly readable (HTTP 200 without auth); add a fallback poster only if other instances behave differently. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b0be9a..8778cad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt index 34e1fba..669b5bf 100644 --- a/app/src/main/java/net/jeena/jtubetv/MainActivity.kt +++ b/app/src/main/java/net/jeena/jtubetv/MainActivity.kt @@ -3,6 +3,7 @@ package net.jeena.jtubetv import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator @@ -12,6 +13,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import net.jeena.jtubetv.BuildConfig import androidx.lifecycle.viewmodel.compose.viewModel import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.SessionState @@ -22,16 +24,31 @@ import net.jeena.jtubetv.ui.theme.JTubeTvTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val viewModel: AppViewModel by viewModels() + seedFromIntent(viewModel) setContent { JTubeTvTheme { - AppRoot() + AppRoot(viewModel) } } } + + /** + * Debug-only automation hook: `am start ... --es jtube_url ... --es jtube_user ... + * --es jtube_pass ...` performs the login without IME gymnastics. Never available + * in release builds. + */ + private fun seedFromIntent(viewModel: AppViewModel) { + if (!BuildConfig.DEBUG) return + val url = intent.getStringExtra("jtube_url") ?: return + val user = intent.getStringExtra("jtube_user") ?: return + val pass = intent.getStringExtra("jtube_pass") ?: return + viewModel.login(url, user, pass) + } } @Composable -private fun AppRoot(viewModel: AppViewModel = viewModel()) { +private fun AppRoot(viewModel: AppViewModel) { val session by viewModel.sessionState.collectAsState() Surface(modifier = Modifier.fillMaxSize(), color = androidx.compose.material3.MaterialTheme.colorScheme.background) { diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index ec2ffb3..cee4fe0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -110,8 +110,8 @@ class PeerTubeRepository( // ---- playback ---------------------------------------------------------- - suspend fun video(shortUuid: String): Result = - runCatching { network.api.video(shortUuid) } + suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result = + runCatching { network.api.video(shortUuid, userHistory = userHistory) } suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result = runCatching { network.api.watching(shortUuid, WatchingBody(currentTimeSeconds)) } @@ -146,6 +146,9 @@ class PeerTubeRepository( fun hasSession(): Boolean = store.hasSession() + /** Full logout (feature request from UX review): forget URL, username and tokens. */ + fun signOut() = store.clearAll() + class BadCredentialsException : Exception("Invalid credentials") private fun plainOkHttp(): OkHttpClient = 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 index f335486..52a1de0 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -91,6 +91,11 @@ data class StreamingPlaylist( val files: List = emptyList(), ) +@Serializable +data class UserHistoryDto( + val currentTime: Int? = null, +) + @Serializable data class VideoDetailDto( val id: Long, @@ -107,6 +112,7 @@ data class VideoDetailDto( val channel: VideoChannelSummary, val files: List = emptyList(), val streamingPlaylists: List = emptyList(), + val userHistory: UserHistoryDto? = null, ) @Serializable 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 index 5b24be4..76dd8e2 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -95,6 +95,7 @@ interface PeerTubeApi { @GET("videos/{shortUuid}") suspend fun video( @Path("shortUuid") shortUuid: String, + @Query("userHistory") userHistory: Boolean? = null, ): VideoDetailDto @POST("videos/{shortUuid}/watching") diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 03721c3..b615684 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -30,9 +30,9 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit fun login(baseUrl: String, username: String, password: String) { viewModelScope.launch { loggingIn.value = true - loginError.value = null + loginError.value = null val result = repository.login(baseUrl, username, password) - loggingIn.value = false + loggingIn.value = false if (result.isSuccess) { _sessionState.value = SessionState.Ready } else { @@ -44,4 +44,10 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit } } } + + /** Clears the stored session; the user returns to the setup screen. */ + fun logout() { + repository.signOut() + _sessionState.value = SessionState.NeedsSetup + } } \ 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 index afe4a03..c0a4d3f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -132,6 +132,7 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), + colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { val avatar = channel.avatars.maxByOrNull { it.width } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt new file mode 100644 index 0000000..91d4d8a --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -0,0 +1,195 @@ +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Text as TvText +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.PeerTubeRepository +import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.list.VideoListViewModel +import net.jeena.jtubetv.ui.screens.list.VideoCard + +private const val ROW_CARD_WIDTH = 296 + +/** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ +data class ContinueItem(val video: VideoDto, val progress: Float?) + +class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { + + var continueItems by mutableStateOf>(emptyList()) + private set + var continueLoading by mutableStateOf(true) + private set + private var loaded = false + + val recent = RecentVideosViewModel(repository) + + /** History's newest entries + their resume positions from video detail */ + fun load() { + if (loaded) return + loaded = true + viewModelScope.launch { + val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } + if (history == null) { + continueLoading = false + return@launch + } + // resume info needs one detail call with ?userHistory=true per entry; + // keep it small - only the first six videos. + val items = history.take(6).map { entry -> + async { + val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() + val watched = detail?.userHistory?.currentTime ?: 0 + val progress = if (detail != null && detail.duration > 0 && watched > 0) { + (watched.toFloat() / detail.duration).coerceIn(0.001f, 0.99f) + } else null + ContinueItem(entry.video, progress) + } + }.awaitAll() + continueItems = items + continueLoading = false + } + } + + companion object { + val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + HomeRowsViewModel(AppGraph.repository) as T + } + } +} + +/** Paged "New videos" row content. */ +class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + override suspend fun fetch(start: Long, count: Int): Result> = + repository.localVideos(start, count).map { it.data } + + companion object { + fun factory(repo: PeerTubeRepository): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RecentVideosViewModel(repo) as T + } + } +} + +/** + * SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress + * bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally. + */ +@Composable +fun HomeRowsScreen( + onOpenVideo: (VideoDto) -> Unit, + viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), +) { + LaunchedEffect(Unit) { viewModel.load() } + val recentItems = viewModel.recent.items + + Column(modifier = Modifier.fillMaxSize()) { + if (viewModel.continueItems.isNotEmpty()) { + VideoRow( + header = "Continue watching", + videos = viewModel.continueItems.map { it.video }, + progressOf = { shortUUID -> + viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress + }, + onOpenVideo = onOpenVideo, + // already limited to 6; no paging + onNearEnd = { }, + ) + } + VideoRow( + header = "New videos", + videos = recentItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onNearEnd = { viewModel.recent.loadMore() }, + ) + } +} + +@Composable +private fun VideoRow( + header: String, + videos: List, + progressOf: (String) -> Float?, + onOpenVideo: (VideoDto) -> Unit, + onNearEnd: () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + TvText( + text = header, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + ) + if (videos.isEmpty()) { + LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows + Box( + Modifier + .fillMaxWidth() + .height(64.dp), + contentAlignment = Alignment.CenterStart, + ) { + TvText( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + modifier = Modifier.padding(start = 16.dp), + ) + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(videos) { index, video -> + VideoCard( + video = video, + onClick = { onOpenVideo(video) }, + modifier = Modifier.width(ROW_CARD_WIDTH.dp), + showPrivacy = false, + progress = progressOf(video.shortUUID), + ) + if (index >= videos.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { onNearEnd() } + } + } + } + } + } + } +} \ 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 index 537bbe1..900a3f7 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -8,6 +8,7 @@ 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.Logout import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.VideoLibrary @@ -24,11 +25,13 @@ 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.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import androidx.activity.compose.BackHandler enum class Destination(val title: String) { - LocalVideos("Videos"), + Home("Home"), MyVideos("My videos"), Channels("Channels"), Search("Search"), @@ -36,61 +39,76 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. */ +/** Home = sidebar (D-pad navigable) + content pane. The player renders as a + * fullscreen overlay: the list beneath stays composed, so its scroll position and + * focused card survive BACK (couch users don't want to re-navigate from the top). */ @Composable -fun HomeScreen() { - var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) } +fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { + var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID - if (playingVideo != null) { - PlayerScreen( - shortUuid = playingVideo!!, - onBack = { playingVideo = null }, - ) - return + // Channel drilldown hides the sidebar; remote BACK must return to the channels list. + BackHandler(enabled = playingVideo == null && channelDetails != null) { + channelDetails = null } - if (destination == Destination.Channels && channelDetails != null) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - ) - return - } - - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, + Box(modifier = Modifier.fillMaxSize()) { + if (destination == Destination.Channels && channelDetails != null) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins (list content, not the player). + .padding(horizontal = 32.dp), + ) { + VideoListScreen( + source = ListSource.Channel(channelDetails!!, channelDetails!!), + onOpenVideo = { playingVideo = it.shortUUID }, ) } - } - }) { - Row(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (destination) { - Destination.LocalVideos -> - VideoListScreen( - source = ListSource.LocalVideos, - onOpenVideo = { playingVideo = it.shortUUID }, + } else { + NavigationDrawer(drawerContent = { + androidx.compose.foundation.layout.Column( + modifier = Modifier.width(220.dp).padding(vertical = 16.dp), + ) { + Text( + text = "JTube TV", + modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), + style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, + color = androidx.compose.ui.graphics.Color.White, + ) + Destination.entries.forEach { dest -> + NavigationDrawerItem( + selected = dest == destination, + onClick = { + destination = dest + channelDetails = null + }, + leadingContent = { + Icon(imageVector = dest.icon(), contentDescription = null) + }, + content = { Text(dest.title) }, ) + } + NavigationDrawerItem( + selected = false, + onClick = { viewModel.logout() }, + leadingContent = { + Icon(imageVector = Icons.Filled.Logout, contentDescription = null) + }, + content = { Text("Sign out") }, + ) + } + }) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 16.dp), + ) { + when (destination) { + Destination.Home -> + HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, @@ -115,14 +133,22 @@ fun HomeScreen() { onOpenVideo = { playingVideo = it.shortUUID }, ) } + } } } + + if (playingVideo != null) { + PlayerScreen( + shortUuid = playingVideo!!, + onBack = { playingVideo = null }, + ) + } } } private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = when (this) { - Destination.LocalVideos -> Icons.Filled.VideoLibrary + Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo Destination.Channels -> Icons.AutoMirrored.Filled.List Destination.Search -> Icons.Filled.Search 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 index 5976dc9..b3f8cbf 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -177,10 +177,14 @@ private fun PlayerSurface( if (player.isPlaying) { val now = System.currentTimeMillis() val position = player.currentPosition + android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms") if (position >= 5_000 && now - lastReported >= 20_000) { + android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s") viewModel.reportWatching(shortUuid, (position / 1000).toInt()) lastReported = now } + } else { + android.util.Log.d("JTubeWatch", "tick not playing") } android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) } 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 index 52e15bb..b0f2614 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -18,6 +19,7 @@ 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.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @@ -34,8 +36,11 @@ fun SetupScreen( val url = rememberSaveable { mutableStateOf("https://tube.jeena.net") } val username = rememberSaveable { mutableStateOf("") } val password = rememberSaveable { mutableStateOf("") } + val showPassword = rememberSaveable { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current val busy = loggingIn.collectAsState() val message = error.collectAsState() + val localError = rememberSaveable { mutableStateOf(null) } Column( modifier = Modifier @@ -59,6 +64,7 @@ fun SetupScreen( label = { Text("PeerTube instance URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = username.value, @@ -66,21 +72,60 @@ fun SetupScreen( label = { Text("Username") }, singleLine = true, modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) TextField( value = password.value, onValueChange = { password.value = it }, label = { Text("Password") }, singleLine = true, + visualTransformation = + if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + colors = fieldColors(), ) - if (message.value != null) { - Text(message.value!!, color = MaterialTheme.colorScheme.error) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.TextButton(onClick = { showPassword.value = !showPassword.value }) { + Text(if (showPassword.value) "Hide password" else "Show password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + password.value = it + } + }, + ) { + Text("Paste password") + } + androidx.compose.material3.TextButton( + onClick = { + clipboard.getText()?.toString()?.takeIf { it.isNotBlank() }?.let { + username.value = it + } + }, + ) { + Text("Paste username") + } + } + if (message.value != null || localError.value != null) { + Text(message.value ?: localError.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(), + // Deliberately always focusable: on TV a permanently disabled button + // is unreachable and leaves the user stuck without feedback (UX review). + // Validation happens on press with an inline message instead. + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + enabled = !busy.value, modifier = Modifier.fillMaxWidth(), ) { if (busy.value) { @@ -92,3 +137,11 @@ fun SetupScreen( } } } + +/** Neutral focus colors; the red primary reads as "error" on form fields (UX review). */ +@Composable +private fun fieldColors() = androidx.compose.material3.OutlinedTextFieldDefaults.colors( + focusedBorderColor = androidx.compose.ui.graphics.Color.White, + focusedLabelColor = androidx.compose.ui.graphics.Color.White, + unfocusedLabelColor = androidx.compose.ui.graphics.Color(0xFFB8B8B8), +) 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 index 75fc84c..5eaa1c1 100644 --- 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 @@ -8,11 +8,7 @@ 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 @@ -23,80 +19,110 @@ 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.tv.material3.Card +import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto +import java.time.Duration +import java.time.Instant +import java.time.format.DateTimeParseException import java.util.Locale -/** Horizontal card: thumbnail left, title/channel/privacy right. Focused via D-pad. */ +/** + * Grid card: 16:9 thumbnail on top, title/meta underneath. tv-material Card gives the + * D-pad focus treatment (scale + 3dp border) for free. + * + * [showPrivacy] is on for "My videos" only — on a personal instance the Internal + * badge on every card is noise (UX review finding). + */ @Composable fun VideoCard( video: VideoDto, onClick: () -> Unit, modifier: Modifier = Modifier, + showPrivacy: Boolean = true, + progress: Float? = null, ) { 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), + .height(268.dp) + .padding(6.dp), + colors = CardDefaults.colors(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, + Box( + modifier = Modifier + .fillMaxWidth() + .height(158.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 + .fillMaxWidth() + .height(158.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), + ) + } + progress?.takeIf { it in 0.001f..0.999f }?.let { fraction -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .align(Alignment.BottomStart) + .background(Color(0x55FFFFFF)), + ) { + Box( 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), + .fillMaxWidth(fraction) + .height(4.dp) + .background(Color(0xFFCE2B2B)), ) } } - Column( - modifier = Modifier - .padding(12.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + } + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = video.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = video.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, + text = video.channel.displayName.ifBlank { video.channel.name }, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFB8B8B8), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - 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 -> + video.privacy?.takeIf { it.id != 1 }?.let { privacy -> + if (showPrivacy) { Text( text = privacy.label ?: "non-public", style = MaterialTheme.typography.labelSmall, @@ -106,22 +132,21 @@ fun VideoCard( .padding(horizontal = 6.dp, vertical = 2.dp), ) } - if (video.nsfw) { - Text( - text = "NSFW", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFFE2A57C), - ) - } } - if (video.views >= 0) { + if (video.nsfw) { Text( - text = formatViews(video.views), - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + text = "NSFW", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFE2A57C), ) } } + Text( + text = formatAge(video.publishedAt) + + if (video.views > 0) " · " + formatViews(video.views) else "", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + ) } } } @@ -142,4 +167,21 @@ internal fun formatViews(views: Long): String = 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 + } + +internal fun formatAge(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return try { + val age = Duration.between(Instant.parse(iso), Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now") + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } + } catch (_: DateTimeParseException) { + "" + } +} \ 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 index b6f278f..3bb45e0 100644 --- 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 @@ -5,8 +5,9 @@ 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.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -14,12 +15,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +44,15 @@ fun VideoListScreen( ), ) { val items = viewModel.items + // Pull initial focus out of the sidebar rail and into the content grid. + val initialFocus = remember { FocusRequester() } + LaunchedEffect(items.isNotEmpty(), source) { + if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + } + // Scroll position survives destination switches (per source), not just player exits. + val gridState = androidx.compose.runtime.saveable.rememberSaveable( + key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + ) { androidx.compose.foundation.lazy.grid.LazyGridState() } when (source) { is ListSource.Search -> SearchHeader(viewModel) @@ -82,8 +95,12 @@ fun VideoListScreen( return } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, item -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), + ) { + itemsIndexed(items) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.Video -> item.video @@ -92,6 +109,8 @@ fun VideoListScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, + showPrivacy = source is ListSource.MyVideos, + modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } From 9d89c1c060798dde64c1937ec0c14667d77d1b67 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 12:23:11 +0000 Subject: [PATCH 07/53] player,ui: Fix the remote-blocking bugs from the Claude UX run Claude Code drove the emulator through the TESTS.md checklist (reports/ux-2026-10-10.md) and found the release blockers. Fixes: player ignores the D-pad after controller auto-hide (BUG-2, focus + key forwarding: CENTER play/pause, LEFT/RIGHT seek, others show controls), Channels 404 for email logins (BUG-1, authenticate the users/me call at login), focus + history lost after BACK (BUG-3/7, restore token re-focuses the opened card and History/Continue watching refresh), Home rows clipped below 1080px (BUG-4, scrollable LazyColumn), text-field D-pad focus traps (BUG-5, IME actions + key escapes), Continue watching started at 0:00 and overwrote server position (BUG-6, resume from userHistory + persist on exit), leaked report timer (BUG-8), token sent to third-party media hosts (BUG-9, ResolvingDataSource appends videoFileToken per request, Bearer only for instance host), player error Retry (BUG-10), raw error strings (BUG-13), y-ago format bug (BUG-14), sign-out double press (BUG-15), T18 expectation corrected (server omits private videos from history). Verified on emuluator: CENTER pauses + shows controller; BACK returns to refreshed Continue watching with progress bars. --- .gitignore | 2 +- TESTS.md | 2 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 23 +- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 13 +- .../jtubetv/ui/list/PagedListViewModel.kt | 21 ++ .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 136 ++++++----- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 31 ++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 216 ++++++++++++++---- .../jeena/jtubetv/ui/screens/SetupScreen.kt | 34 ++- .../jtubetv/ui/screens/list/VideoCard.kt | 2 +- .../ui/screens/list/VideoListScreen.kt | 48 +++- 11 files changed, 411 insertions(+), 117 deletions(-) diff --git a/.gitignore b/.gitignore index 0935029..f89a0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ /app/build /captures .externalNativeBuild -.cxx \ No newline at end of file +.cxxreports/*-shots/ diff --git a/TESTS.md b/TESTS.md index 5d79d5a..46dbb1b 100644 --- a/TESTS.md +++ b/TESTS.md @@ -71,7 +71,7 @@ APK version + device. - [ ] T17 Click remote video → plays or fails *gracefully* (a remote video's media is on the remote host; MVP may fail to resolve — must show error toast/message + back, never crash/freeze). ### 5 — History -- [ ] T18 History lists server-side history newest-first, incl. both public and private watched entries. +- [ ] T18 History lists server-side history newest-first. NOTE (verified 2026-10-10): PeerTube's history endpoint OMITS private videos — private test entries will not appear; adjust expectations accordingly (app is fine). - [ ] T19 Play an entry to ≥ 20 s, leave (back) → entry is present/updated in history when re-opening the screen (server round-trip within ~1 s; app must not show stale cache as fresh — reload on screen entry). ### 6 — Playback diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index cee4fe0..50e2eee 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -67,11 +67,28 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // Logins accept emails; resolve the real account name for /accounts/{handle}. + // Login accepts emails; resolve the real account name for /accounts/{handle}. + // The request MUST carry the fresh token (UX report BUG-1: unauthenticated + // /users/me returns 401 and the fallback breaks Channels for email logins). try { - store.accountName = api.me().username + val authed = plainOkHttp().newBuilder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Authorization", "Bearer ${token.accessToken}") + .build() + ) + } + .build() + val meApi = Retrofit.Builder() + .baseUrl(apiUrlOf(base) + "/") + .client(authed) + .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) + .build() + .create(PeerTubeApi::class.java) + store.accountName = meApi.me().username } catch (_: Exception) { - store.accountName = username // best effort fallback + store.accountName = username.substringBefore('@') // best effort fallback } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index b615684..247057d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -39,12 +39,23 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" } else { - "Could not reach instance: " + (result.exceptionOrNull()?.message ?: "unknown error") + friendlyLoginError(result.exceptionOrNull()) } } } } + private fun friendlyLoginError(e: Throwable?): String { + val msg = e?.message ?: "" + return when { + msg.contains("404") -> "That URL doesn't look like a PeerTube instance" + msg.contains("Unable to resolve", ignoreCase = true) -> + "Can't reach the server — check the URL and your connection" + msg.contains("401") || msg.contains("403") -> "Your account can't access this instance" + else -> "Could not reach the instance: " + msg.ifBlank { "unknown error" } + } + } + /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() 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 index 043da62..c96c5bc 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -54,6 +54,27 @@ abstract class PagedListViewModel : ViewModel() { } } + /** + * Silently replace items with a fresh first page (UX report BUG-7): keeps the + * old list visible while loading, no spinner flash. + */ + fun refresh() { + if (inFlight) return + inFlight = true + viewModelScope.launch { + try { + val result = fetch(0, pageSize) + result.onSuccess { fresh -> + exhausted = fresh.size < pageSize + items = fresh + initialError = null + } + } finally { + inFlight = false + } + } + } + fun retryInitial() { items = emptyList() exhausted = false diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 91d4d8a..a884dc0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -1,15 +1,14 @@ 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.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme @@ -18,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Alignment import androidx.compose.ui.Modifier @@ -36,10 +34,9 @@ import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel -import net.jeena.jtubetv.ui.list.VideoListViewModel import net.jeena.jtubetv.ui.screens.list.VideoCard -private const val ROW_CARD_WIDTH = 296 +private val ROW_CARD_WIDTH = 296.dp /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ data class ContinueItem(val video: VideoDto, val progress: Float?) @@ -54,18 +51,31 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( val recent = RecentVideosViewModel(repository) - /** History's newest entries + their resume positions from video detail */ + /** History's newest entries + resume positions from the video detail endpoint. */ fun load() { if (loaded) return loaded = true + fetchContinue() + } + + /** Re-fetch after playback changed server-side positions (UX report BUG-7). */ + fun refresh() { + continueItems = emptyList() + loaded = true + fetchContinue() + recent.refresh() + } + + private fun fetchContinue() { viewModelScope.launch { + continueLoading = true val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } if (history == null) { continueLoading = false return@launch } - // resume info needs one detail call with ?userHistory=true per entry; - // keep it small - only the first six videos. + // Resume info needs one detail call with ?userHistory=true per entry; + // keep it small — only the first six videos. val items = history.take(6).map { entry -> async { val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() @@ -106,69 +116,35 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL } /** - * SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress - * bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally. + * SmartTube-style home: stacked horizontal rows ("Continue watching" with progress + * bars, "New videos" paged) inside a scrollable LazyColumn (UX report BUG-4: the + * plain Column clipped the second row below 1080 px). */ @Composable fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } + LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } val recentItems = viewModel.recent.items - - Column(modifier = Modifier.fillMaxSize()) { - if (viewModel.continueItems.isNotEmpty()) { - VideoRow( - header = "Continue watching", - videos = viewModel.continueItems.map { it.video }, - progressOf = { shortUUID -> - viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress - }, - onOpenVideo = onOpenVideo, - // already limited to 6; no paging - onNearEnd = { }, - ) - } - VideoRow( - header = "New videos", - videos = recentItems, - progressOf = { null }, - onOpenVideo = onOpenVideo, - onNearEnd = { viewModel.recent.loadMore() }, - ) - } -} - -@Composable -private fun VideoRow( - header: String, - videos: List, - progressOf: (String) -> Float?, - onOpenVideo: (VideoDto) -> Unit, - onNearEnd: () -> Unit, -) { - Box(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.fillMaxWidth()) { - TvText( - text = header, + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Text( + text = "Continue watching", style = MaterialTheme.typography.titleMedium, color = Color.White, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), ) - if (videos.isEmpty()) { - LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows - Box( - Modifier - .fillMaxWidth() - .height(64.dp), - contentAlignment = Alignment.CenterStart, - ) { - TvText( - text = "Loading…", + } + item { + if (viewModel.continueItems.isEmpty()) { + Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { + Text( + text = if (viewModel.continueLoading) "Loading…" else "Watch something first", style = MaterialTheme.typography.bodySmall, color = Color(0xFF8A8A8A), - modifier = Modifier.padding(start = 16.dp), ) } } else { @@ -176,16 +152,50 @@ private fun VideoRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(videos) { index, video -> + itemsIndexed(viewModel.continueItems) { _, item -> + VideoCard( + video = item.video, + onClick = { onOpenVideo(item.video) }, + modifier = Modifier.width(ROW_CARD_WIDTH), + showPrivacy = false, + progress = item.progress, + ) + } + } + } + } + item { + Text( + text = "New videos", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) + } + item { + if (recentItems.isEmpty()) { + LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } + Row(Modifier.padding(start = 16.dp)) { + Text( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + ) + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(recentItems) { index, video -> VideoCard( video = video, onClick = { onOpenVideo(video) }, - modifier = Modifier.width(ROW_CARD_WIDTH.dp), + modifier = Modifier.width(ROW_CARD_WIDTH), showPrivacy = false, - progress = progressOf(video.shortUUID), ) - if (index >= videos.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { onNearEnd() } + if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { viewModel.recent.loadMore() } } } } 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 index 900a3f7..18325e9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -14,7 +14,10 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -47,6 +50,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends + + // After BACK from the player: bump the restore token so lists re-focus the opened + // card and re-fetch positions (UX report BUG-3 + BUG-7). + LaunchedEffect(playingVideo) { + if (playingVideo == null && restoreToken >= 1) restoreToken++ + } // Channel drilldown hides the sidebar; remote BACK must return to the channels list. BackHandler(enabled = playingVideo == null && channelDetails != null) { @@ -64,6 +74,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.Channel(channelDetails!!, channelDetails!!), onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) } } else { @@ -90,9 +101,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi content = { Text(dest.title) }, ) } + val signOutContext = androidx.compose.ui.platform.LocalContext.current + var lastSignOutTap by remember { mutableStateOf(0L) } NavigationDrawerItem( selected = false, - onClick = { viewModel.logout() }, + onClick = { + val now = System.currentTimeMillis() + if (now - lastSignOutTap < 3000) { + viewModel.logout() + } else { + lastSignOutTap = now + android.widget.Toast.makeText( + signOutContext, + "Press again to sign out", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + }, leadingContent = { Icon(imageVector = Icons.Filled.Logout, contentDescription = null) }, @@ -113,6 +138,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.MyVideos, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -121,16 +147,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.Search, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.Subscriptions -> VideoListScreen( source = ListSource.Subscriptions, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.History -> VideoListScreen( source = ListSource.History, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) } } 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 index b3f8cbf..7e27467 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -2,17 +2,30 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -20,9 +33,12 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.PlayerView @@ -33,10 +49,18 @@ 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 +import android.net.Uri sealed interface PlayerUiState { data object Loading : PlayerUiState - data class Ready(val detail: VideoDetailDto, val playUrl: String, val isHls: Boolean) : PlayerUiState + data class Ready( + val detail: VideoDetailDto, + val playUrl: String, + val isHls: Boolean, + val fileToken: String? = null, + /** If the server has a stored position, show "Resuming at …" on start. */ + val resumeAtSeconds: Int? = null, + ) : PlayerUiState data class Error(val message: String) : PlayerUiState } @@ -48,12 +72,15 @@ class PlayerViewModel( private val _state = MutableStateFlow(PlayerUiState.Loading) val state: StateFlow = _state + var currentShortUuid: String? = null + private set + fun load(shortUuid: String) { - if (_state.value != PlayerUiState.Loading) return + currentShortUuid = shortUuid viewModelScope.launch { - // Freshen the token before media requests hit it. - repository.me() // triggers refresh via OkHttp authenticator if needed - val result = repository.video(shortUuid) + // Freshen the token before media requests hit it (triggers refresh on 401). + repository.me() + val result = repository.video(shortUuid, userHistory = true) result.fold( onSuccess = { detail -> val play = repository.playoutUrl(detail) @@ -63,15 +90,13 @@ class PlayerViewModel( // Non-public media may need a short-lived file token instead of // (or in addition to) the Bearer header — matters on instances // whose object-storage bucket rejects anonymous requests. - var url = play.url + var token: String? = null if (detail.privacy?.id != 1) { - repository.videoToken(detail.shortUUID).onSuccess { token -> - url += (if (url.contains('?')) "&" else "?") + - "videoFileToken=" + - java.net.URLEncoder.encode(token, "UTF-8") - } + token = repository.videoToken(detail.shortUUID).getOrNull() } - _state.value = PlayerUiState.Ready(detail, url, play.isHls) + val resumeAt = detail.userHistory?.currentTime + ?.takeIf { detail.duration > 0 && it > 5 && it < detail.duration * 95 / 100 } + _state.value = PlayerUiState.Ready(detail, play.url, play.isHls, token, resumeAt) } }, onFailure = { e -> @@ -81,10 +106,24 @@ class PlayerViewModel( } } - fun reportWatching(shortUuid: String, currentTimeSeconds: Int) { + fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl + fun isInstanceMedia(): Boolean { + val url = currentUrl() ?: return false + val base = tokenStore.baseUrl?.removePrefix("https://")?.removePrefix("http://")?.substringBefore('/') + return url.substringAfter("://").substringBefore('/') == base + } + + /** Send the position once more when leaving playback (avoids losing up to 20 s). */ + fun persistExit(shortUuid: String, currentTimeSeconds: Int) { viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } } + fun retry() { + val uuid = currentShortUuid ?: return + _state.value = PlayerUiState.Loading + load(uuid) + } + companion object { fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -96,8 +135,10 @@ class PlayerViewModel( } /** - * Fullscreen ExoPlayer. Feature 6: reports currentTime to the PeerTube server every - * ~20 s so the watch shows up in the server-side history. + * Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus + * lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s, + * other keys show the controller. The player is an overlay; BACK restores the list + * underneath (BUG-3) and sends a final watching report (BUG-6). */ @Composable fun PlayerScreen( @@ -119,8 +160,8 @@ fun PlayerScreen( when (val s = state) { is PlayerUiState.Loading -> PlayerLoading() - is PlayerUiState.Error -> PlayerError(s.message, onBack) - is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid) + is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack) } } @@ -132,67 +173,148 @@ private fun PlayerLoading() { } @Composable -private fun PlayerError(message: String, onBack: () -> Unit) { +private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () -> Unit) { + val context = LocalContext.current + val initialFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { initialFocus.requestFocus() } } Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { - androidx.compose.material3.Button(onClick = onBack) { - androidx.compose.material3.Text("Close ($message)") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + friendlyMediaError(message), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) + androidx.compose.material3.Text( + message, + color = Color(0xFF8A8A8A), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + ) + androidx.compose.material3.Button( + onClick = { viewModel.retry() }, + modifier = Modifier + .focusRequester(initialFocus) + .focusable(), + ) { Text("Retry") } + androidx.compose.material3.Button( + onClick = { + Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show() + onBack() + }, + modifier = Modifier.focusable(), + ) { Text("Close") } } } } +internal fun friendlyMediaError(message: String): String = + when { + message.contains("Unable to resolve", ignoreCase = true) -> + "Can't reach the server — check the connection" + message.contains("404") -> "This doesn't look like a PeerTube instance" + message.contains("401") || message.contains("403") -> "Not allowed to watch this video — try signing in again" + message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection" + else -> "Playback error" + } + @Composable private fun PlayerSurface( s: PlayerUiState.Ready, viewModel: PlayerViewModel, shortUuid: String, + onBack: () -> Unit, ) { - val context = androidx.compose.ui.platform.LocalContext.current - val player = androidx.compose.runtime.remember { + val context = LocalContext.current + val keyFocus = remember { FocusRequester() } + val player = remember { + // Bearer headers only for instance-served media; never send the user's token to + // third-party storage hosts (UX report BUG-9). The file token is appended per + // request (incl. variant playlists + segments) via ResolvingDataSource. + val hostIsInstance = isInstanceHost(s.playUrl, AppGraph.tokenStore.baseUrl) + val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) { + mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}") + } else emptyMap() 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) + .setDefaultRequestProperties(headers) + val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec -> + val token = s.fileToken ?: return@Factory dataSpec + val uri = dataSpec.uri.buildUpon() + .appendQueryParameter("videoFileToken", token) + .build() + dataSpec.buildUpon().setUri(uri).build() + } + val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving) ExoPlayer.Builder(context) - .setMediaSourceFactory(mediaSourceFactory) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSource)) .build() } + LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } } + 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) + s.resumeAtSeconds?.let { + player.setMediaItem(item, it * 1000L) + Toast.makeText( + context, + "Resuming at ${it / 60}:${"%02d".format(it % 60)}", + Toast.LENGTH_LONG, + ).show() + } ?: player.setMediaItem(item) player.prepare() player.playWhenReady = true - onDispose { player.release() } + onDispose { + viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) + player.release() + } } - // Periodic watching report (server-side history). + // Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8). DisposableEffect(player) { var lastReported = 0L + val handler = android.os.Handler(android.os.Looper.getMainLooper()) val runnable = object : Runnable { override fun run() { if (player.isPlaying) { val now = System.currentTimeMillis() val position = player.currentPosition - android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms") if (position >= 5_000 && now - lastReported >= 20_000) { - android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s") - viewModel.reportWatching(shortUuid, (position / 1000).toInt()) + viewModel.persistExit(shortUuid, (position / 1000).toInt()) lastReported = now } - } else { - android.util.Log.d("JTubeWatch", "tick not playing") } - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) + handler.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) - } + handler.postDelayed(runnable, 5_000) + onDispose { handler.removeCallbacks(runnable) } + } + + val handleKey: (android.view.KeyEvent) -> Boolean = { event -> + if (event.action == android.view.KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_CENTER, + android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + -> { + player.playWhenReady = !player.playWhenReady + true + } + android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0)) + true + } + android.view.KeyEvent.KEYCODE_DPAD_RIGHT, android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.seekTo(player.currentPosition + 10_000) + true + } + else -> false // UP/DOWN etc.: show the controller + } + } else false } AndroidView( @@ -201,8 +323,24 @@ private fun PlayerSurface( useController = true this.player = player controllerShowTimeoutMs = 4000 + isFocusable = false + descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS } }, - modifier = Modifier.fillMaxSize().background(Color.Black), + update = { }, + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(keyFocus) + .focusable() + .onPreviewKeyEvent { event -> + handleKey(event.nativeKeyEvent) + }, ) +} + +private fun isInstanceHost(url: String, baseUrl: String?): Boolean { + val urlHost = url.substringAfter("://").substringBefore('/') + val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/') + return baseHost != null && urlHost == baseHost } \ 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 index b0f2614..fc01343 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -19,6 +19,9 @@ 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.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @@ -38,6 +41,7 @@ fun SetupScreen( val password = rememberSaveable { mutableStateOf("") } val showPassword = rememberSaveable { mutableStateOf(false) } val clipboard = LocalClipboardManager.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val busy = loggingIn.collectAsState() val message = error.collectAsState() val localError = rememberSaveable { mutableStateOf(null) } @@ -63,7 +67,15 @@ fun SetupScreen( onValueChange = { url.value = it }, label = { Text("PeerTube instance URL") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) TextField( @@ -71,7 +83,15 @@ fun SetupScreen( onValueChange = { username.value = it }, label = { Text("Username") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) TextField( @@ -82,7 +102,15 @@ fun SetupScreen( visualTransformation = if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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 index 5eaa1c1..8d67b29 100644 --- 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 @@ -175,7 +175,7 @@ internal fun formatAge(iso: String?): String { val age = Duration.between(Instant.parse(iso), Instant.now()) val days = age.toDays() when { - days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now") + days >= 365 -> "${days / 365}y ago" days >= 30 -> "${days / 30}mo ago" days >= 1 -> "${days}d ago" age.toHours() >= 1 -> "${age.toHours()}h ago" 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 index 3bb45e0..6271151 100644 --- 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 @@ -24,6 +24,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -38,17 +41,35 @@ import net.jeena.jtubetv.ui.list.VideoListViewModel fun VideoListScreen( source: ListSource, onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, viewModel: VideoListViewModel = viewModel( key = sourceKey(source), factory = VideoListViewModel.factory(source), ), ) { val items = viewModel.items + // per-item focus requesters so BACK from the player can refocus the opened card + val requesters = remember(items.size) { + androidx.compose.runtime.mutableStateMapOf() + } // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } + // Which card was opened last — re-focus it when playback ends (UX report BUG-3). + var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } } + LaunchedEffect(restoreToken) { + if (restoreToken > 0) { + if (source is ListSource.History || source is ListSource.MyVideos) { + viewModel.refresh() + } + // focus the card that was playing; fall back to the first card + val target = requesters[lastOpenedIndex] ?: initialFocus + runCatching { target.requestFocus() } + lastOpenedIndex = -1 + } + } // Scroll position survives destination switches (per source), not just player exits. val gridState = androidx.compose.runtime.saveable.rememberSaveable( key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, @@ -106,11 +127,15 @@ fun VideoListScreen( is VideoListItem.Video -> item.video else -> return@itemsIndexed } + val fr = remember { FocusRequester() } + requesters[index] = fr VideoCard( video = video, - onClick = { onOpenVideo(video) }, + onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, - modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier, + modifier = Modifier + .focusRequester(fr) + .also { if (index == 0) Modifier.focusRequester(initialFocus) }, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -129,13 +154,28 @@ fun VideoListScreen( @Composable private fun SearchHeader(viewModel: VideoListViewModel) { var text by remember { mutableStateOf(viewModel.query.value) } + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current Column(Modifier.fillMaxWidth().padding(16.dp)) { TextField( value = text, onValueChange = { text = it }, - label = { Text("Search local videos") }, + label = { Text("Type a search term, then press the search action") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Search, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onSearch = { viewModel.triggerSearch(text); focusManager.clearFocus() }, + ), + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + }, ) Button( onClick = { viewModel.triggerSearch(text) }, From 0b563d28890c89beb238f0073f7cefd68586f130 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 12:23:11 +0000 Subject: [PATCH 08/53] player,ui: Fix the remote-blocking bugs from the Claude UX run Claude Code drove the emulator through the TESTS.md checklist (reports/ux-2026-10-10.md) and found the release blockers. Fixes: player ignores the D-pad after controller auto-hide (BUG-2, focus + key forwarding: CENTER play/pause, LEFT/RIGHT seek, others show controls), Channels 404 for email logins (BUG-1, authenticate the users/me call at login), focus + history lost after BACK (BUG-3/7, restore token re-focuses the opened card and History/Continue watching refresh), Home rows clipped below 1080px (BUG-4, scrollable LazyColumn), text-field D-pad focus traps (BUG-5, IME actions + key escapes), Continue watching started at 0:00 and overwrote server position (BUG-6, resume from userHistory + persist on exit), leaked report timer (BUG-8), token sent to third-party media hosts (BUG-9, ResolvingDataSource appends videoFileToken per request, Bearer only for instance host), player error Retry (BUG-10), raw error strings (BUG-13), y-ago format bug (BUG-14), sign-out double press (BUG-15), T18 expectation corrected (server omits private videos from history). Verified on emuluator: CENTER pauses + shows controller; BACK returns to refreshed Continue watching with progress bars. --- .gitignore | 2 +- TESTS.md | 2 +- .../jeena/jtubetv/data/PeerTubeRepository.kt | 23 +- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 13 +- .../jtubetv/ui/list/PagedListViewModel.kt | 21 ++ .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 136 ++++++----- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 31 ++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 216 ++++++++++++++---- .../jeena/jtubetv/ui/screens/SetupScreen.kt | 34 ++- .../jtubetv/ui/screens/list/VideoCard.kt | 2 +- .../ui/screens/list/VideoListScreen.kt | 48 +++- 11 files changed, 411 insertions(+), 117 deletions(-) diff --git a/.gitignore b/.gitignore index 0935029..f89a0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ /app/build /captures .externalNativeBuild -.cxx \ No newline at end of file +.cxxreports/*-shots/ diff --git a/TESTS.md b/TESTS.md index 80e22f6..2d72f54 100644 --- a/TESTS.md +++ b/TESTS.md @@ -71,7 +71,7 @@ APK version + device. - [ ] T17 Click remote video → plays or fails *gracefully* (a remote video's media is on the remote host; MVP may fail to resolve — must show error toast/message + back, never crash/freeze). ### 5 — History -- [ ] T18 History lists server-side history newest-first, incl. both public and private watched entries. +- [ ] T18 History lists server-side history newest-first. NOTE (verified 2026-10-10): PeerTube's history endpoint OMITS private videos — private test entries will not appear; adjust expectations accordingly (app is fine). - [ ] T19 Play an entry to ≥ 20 s, leave (back) → entry is present/updated in history when re-opening the screen (server round-trip within ~1 s; app must not show stale cache as fresh — reload on screen entry). ### 6 — Playback diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index cee4fe0..50e2eee 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -67,11 +67,28 @@ class PeerTubeRepository( expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 86400L) * 1000L, ) - // Logins accept emails; resolve the real account name for /accounts/{handle}. + // Login accepts emails; resolve the real account name for /accounts/{handle}. + // The request MUST carry the fresh token (UX report BUG-1: unauthenticated + // /users/me returns 401 and the fallback breaks Channels for email logins). try { - store.accountName = api.me().username + val authed = plainOkHttp().newBuilder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Authorization", "Bearer ${token.accessToken}") + .build() + ) + } + .build() + val meApi = Retrofit.Builder() + .baseUrl(apiUrlOf(base) + "/") + .client(authed) + .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) + .build() + .create(PeerTubeApi::class.java) + store.accountName = meApi.me().username } catch (_: Exception) { - store.accountName = username // best effort fallback + store.accountName = username.substringBefore('@') // best effort fallback } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index b615684..247057d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -39,12 +39,23 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" } else { - "Could not reach instance: " + (result.exceptionOrNull()?.message ?: "unknown error") + friendlyLoginError(result.exceptionOrNull()) } } } } + private fun friendlyLoginError(e: Throwable?): String { + val msg = e?.message ?: "" + return when { + msg.contains("404") -> "That URL doesn't look like a PeerTube instance" + msg.contains("Unable to resolve", ignoreCase = true) -> + "Can't reach the server — check the URL and your connection" + msg.contains("401") || msg.contains("403") -> "Your account can't access this instance" + else -> "Could not reach the instance: " + msg.ifBlank { "unknown error" } + } + } + /** Clears the stored session; the user returns to the setup screen. */ fun logout() { repository.signOut() 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 index 043da62..c96c5bc 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -54,6 +54,27 @@ abstract class PagedListViewModel : ViewModel() { } } + /** + * Silently replace items with a fresh first page (UX report BUG-7): keeps the + * old list visible while loading, no spinner flash. + */ + fun refresh() { + if (inFlight) return + inFlight = true + viewModelScope.launch { + try { + val result = fetch(0, pageSize) + result.onSuccess { fresh -> + exhausted = fresh.size < pageSize + items = fresh + initialError = null + } + } finally { + inFlight = false + } + } + } + fun retryInitial() { items = emptyList() exhausted = false diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 91d4d8a..a884dc0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -1,15 +1,14 @@ 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.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme @@ -18,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Alignment import androidx.compose.ui.Modifier @@ -36,10 +34,9 @@ import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel -import net.jeena.jtubetv.ui.list.VideoListViewModel import net.jeena.jtubetv.ui.screens.list.VideoCard -private const val ROW_CARD_WIDTH = 296 +private val ROW_CARD_WIDTH = 296.dp /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ data class ContinueItem(val video: VideoDto, val progress: Float?) @@ -54,18 +51,31 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( val recent = RecentVideosViewModel(repository) - /** History's newest entries + their resume positions from video detail */ + /** History's newest entries + resume positions from the video detail endpoint. */ fun load() { if (loaded) return loaded = true + fetchContinue() + } + + /** Re-fetch after playback changed server-side positions (UX report BUG-7). */ + fun refresh() { + continueItems = emptyList() + loaded = true + fetchContinue() + recent.refresh() + } + + private fun fetchContinue() { viewModelScope.launch { + continueLoading = true val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } if (history == null) { continueLoading = false return@launch } - // resume info needs one detail call with ?userHistory=true per entry; - // keep it small - only the first six videos. + // Resume info needs one detail call with ?userHistory=true per entry; + // keep it small — only the first six videos. val items = history.take(6).map { entry -> async { val detail = repository.video(entry.video.shortUUID, userHistory = true).getOrNull() @@ -106,69 +116,35 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL } /** - * SmartTube-style home: stacked horizontal rows — "Continue watching" (with progress - * bars), "New videos" (paged). Rows scroll vertically; row content scrolls horizontally. + * SmartTube-style home: stacked horizontal rows ("Continue watching" with progress + * bars, "New videos" paged) inside a scrollable LazyColumn (UX report BUG-4: the + * plain Column clipped the second row below 1080 px). */ @Composable fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } + LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } val recentItems = viewModel.recent.items - - Column(modifier = Modifier.fillMaxSize()) { - if (viewModel.continueItems.isNotEmpty()) { - VideoRow( - header = "Continue watching", - videos = viewModel.continueItems.map { it.video }, - progressOf = { shortUUID -> - viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress - }, - onOpenVideo = onOpenVideo, - // already limited to 6; no paging - onNearEnd = { }, - ) - } - VideoRow( - header = "New videos", - videos = recentItems, - progressOf = { null }, - onOpenVideo = onOpenVideo, - onNearEnd = { viewModel.recent.loadMore() }, - ) - } -} - -@Composable -private fun VideoRow( - header: String, - videos: List, - progressOf: (String) -> Float?, - onOpenVideo: (VideoDto) -> Unit, - onNearEnd: () -> Unit, -) { - Box(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.fillMaxWidth()) { - TvText( - text = header, + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Text( + text = "Continue watching", style = MaterialTheme.typography.titleMedium, color = Color.White, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), ) - if (videos.isEmpty()) { - LaunchedEffect(header) { onNearEnd() } // kick the first fetch for empty rows - Box( - Modifier - .fillMaxWidth() - .height(64.dp), - contentAlignment = Alignment.CenterStart, - ) { - TvText( - text = "Loading…", + } + item { + if (viewModel.continueItems.isEmpty()) { + Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { + Text( + text = if (viewModel.continueLoading) "Loading…" else "Watch something first", style = MaterialTheme.typography.bodySmall, color = Color(0xFF8A8A8A), - modifier = Modifier.padding(start = 16.dp), ) } } else { @@ -176,16 +152,50 @@ private fun VideoRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(videos) { index, video -> + itemsIndexed(viewModel.continueItems) { _, item -> + VideoCard( + video = item.video, + onClick = { onOpenVideo(item.video) }, + modifier = Modifier.width(ROW_CARD_WIDTH), + showPrivacy = false, + progress = item.progress, + ) + } + } + } + } + item { + Text( + text = "New videos", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) + } + item { + if (recentItems.isEmpty()) { + LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } + Row(Modifier.padding(start = 16.dp)) { + Text( + text = "Loading…", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF8A8A8A), + ) + } + } else { + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(recentItems) { index, video -> VideoCard( video = video, onClick = { onOpenVideo(video) }, - modifier = Modifier.width(ROW_CARD_WIDTH.dp), + modifier = Modifier.width(ROW_CARD_WIDTH), showPrivacy = false, - progress = progressOf(video.shortUUID), ) - if (index >= videos.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { onNearEnd() } + if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { viewModel.recent.loadMore() } } } } 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 index 900a3f7..18325e9 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -14,7 +14,10 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -47,6 +50,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends + + // After BACK from the player: bump the restore token so lists re-focus the opened + // card and re-fetch positions (UX report BUG-3 + BUG-7). + LaunchedEffect(playingVideo) { + if (playingVideo == null && restoreToken >= 1) restoreToken++ + } // Channel drilldown hides the sidebar; remote BACK must return to the channels list. BackHandler(enabled = playingVideo == null && channelDetails != null) { @@ -64,6 +74,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.Channel(channelDetails!!, channelDetails!!), onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) } } else { @@ -90,9 +101,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi content = { Text(dest.title) }, ) } + val signOutContext = androidx.compose.ui.platform.LocalContext.current + var lastSignOutTap by remember { mutableStateOf(0L) } NavigationDrawerItem( selected = false, - onClick = { viewModel.logout() }, + onClick = { + val now = System.currentTimeMillis() + if (now - lastSignOutTap < 3000) { + viewModel.logout() + } else { + lastSignOutTap = now + android.widget.Toast.makeText( + signOutContext, + "Press again to sign out", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + }, leadingContent = { Icon(imageVector = Icons.Filled.Logout, contentDescription = null) }, @@ -113,6 +138,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.MyVideos, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -121,16 +147,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi VideoListScreen( source = ListSource.Search, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.Subscriptions -> VideoListScreen( source = ListSource.Subscriptions, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) Destination.History -> VideoListScreen( source = ListSource.History, onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, ) } } 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 index b3f8cbf..7e27467 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -2,17 +2,30 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -20,9 +33,12 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.PlayerView @@ -33,10 +49,18 @@ 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 +import android.net.Uri sealed interface PlayerUiState { data object Loading : PlayerUiState - data class Ready(val detail: VideoDetailDto, val playUrl: String, val isHls: Boolean) : PlayerUiState + data class Ready( + val detail: VideoDetailDto, + val playUrl: String, + val isHls: Boolean, + val fileToken: String? = null, + /** If the server has a stored position, show "Resuming at …" on start. */ + val resumeAtSeconds: Int? = null, + ) : PlayerUiState data class Error(val message: String) : PlayerUiState } @@ -48,12 +72,15 @@ class PlayerViewModel( private val _state = MutableStateFlow(PlayerUiState.Loading) val state: StateFlow = _state + var currentShortUuid: String? = null + private set + fun load(shortUuid: String) { - if (_state.value != PlayerUiState.Loading) return + currentShortUuid = shortUuid viewModelScope.launch { - // Freshen the token before media requests hit it. - repository.me() // triggers refresh via OkHttp authenticator if needed - val result = repository.video(shortUuid) + // Freshen the token before media requests hit it (triggers refresh on 401). + repository.me() + val result = repository.video(shortUuid, userHistory = true) result.fold( onSuccess = { detail -> val play = repository.playoutUrl(detail) @@ -63,15 +90,13 @@ class PlayerViewModel( // Non-public media may need a short-lived file token instead of // (or in addition to) the Bearer header — matters on instances // whose object-storage bucket rejects anonymous requests. - var url = play.url + var token: String? = null if (detail.privacy?.id != 1) { - repository.videoToken(detail.shortUUID).onSuccess { token -> - url += (if (url.contains('?')) "&" else "?") + - "videoFileToken=" + - java.net.URLEncoder.encode(token, "UTF-8") - } + token = repository.videoToken(detail.shortUUID).getOrNull() } - _state.value = PlayerUiState.Ready(detail, url, play.isHls) + val resumeAt = detail.userHistory?.currentTime + ?.takeIf { detail.duration > 0 && it > 5 && it < detail.duration * 95 / 100 } + _state.value = PlayerUiState.Ready(detail, play.url, play.isHls, token, resumeAt) } }, onFailure = { e -> @@ -81,10 +106,24 @@ class PlayerViewModel( } } - fun reportWatching(shortUuid: String, currentTimeSeconds: Int) { + fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl + fun isInstanceMedia(): Boolean { + val url = currentUrl() ?: return false + val base = tokenStore.baseUrl?.removePrefix("https://")?.removePrefix("http://")?.substringBefore('/') + return url.substringAfter("://").substringBefore('/') == base + } + + /** Send the position once more when leaving playback (avoids losing up to 20 s). */ + fun persistExit(shortUuid: String, currentTimeSeconds: Int) { viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } } + fun retry() { + val uuid = currentShortUuid ?: return + _state.value = PlayerUiState.Loading + load(uuid) + } + companion object { fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -96,8 +135,10 @@ class PlayerViewModel( } /** - * Fullscreen ExoPlayer. Feature 6: reports currentTime to the PeerTube server every - * ~20 s so the watch shows up in the server-side history. + * Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus + * lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s, + * other keys show the controller. The player is an overlay; BACK restores the list + * underneath (BUG-3) and sends a final watching report (BUG-6). */ @Composable fun PlayerScreen( @@ -119,8 +160,8 @@ fun PlayerScreen( when (val s = state) { is PlayerUiState.Loading -> PlayerLoading() - is PlayerUiState.Error -> PlayerError(s.message, onBack) - is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid) + is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) + is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack) } } @@ -132,67 +173,148 @@ private fun PlayerLoading() { } @Composable -private fun PlayerError(message: String, onBack: () -> Unit) { +private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () -> Unit) { + val context = LocalContext.current + val initialFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { initialFocus.requestFocus() } } Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { - androidx.compose.material3.Button(onClick = onBack) { - androidx.compose.material3.Text("Close ($message)") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + friendlyMediaError(message), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + ) + androidx.compose.material3.Text( + message, + color = Color(0xFF8A8A8A), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + ) + androidx.compose.material3.Button( + onClick = { viewModel.retry() }, + modifier = Modifier + .focusRequester(initialFocus) + .focusable(), + ) { Text("Retry") } + androidx.compose.material3.Button( + onClick = { + Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show() + onBack() + }, + modifier = Modifier.focusable(), + ) { Text("Close") } } } } +internal fun friendlyMediaError(message: String): String = + when { + message.contains("Unable to resolve", ignoreCase = true) -> + "Can't reach the server — check the connection" + message.contains("404") -> "This doesn't look like a PeerTube instance" + message.contains("401") || message.contains("403") -> "Not allowed to watch this video — try signing in again" + message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection" + else -> "Playback error" + } + @Composable private fun PlayerSurface( s: PlayerUiState.Ready, viewModel: PlayerViewModel, shortUuid: String, + onBack: () -> Unit, ) { - val context = androidx.compose.ui.platform.LocalContext.current - val player = androidx.compose.runtime.remember { + val context = LocalContext.current + val keyFocus = remember { FocusRequester() } + val player = remember { + // Bearer headers only for instance-served media; never send the user's token to + // third-party storage hosts (UX report BUG-9). The file token is appended per + // request (incl. variant playlists + segments) via ResolvingDataSource. + val hostIsInstance = isInstanceHost(s.playUrl, AppGraph.tokenStore.baseUrl) + val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) { + mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}") + } else emptyMap() 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) + .setDefaultRequestProperties(headers) + val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec -> + val token = s.fileToken ?: return@Factory dataSpec + val uri = dataSpec.uri.buildUpon() + .appendQueryParameter("videoFileToken", token) + .build() + dataSpec.buildUpon().setUri(uri).build() + } + val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving) ExoPlayer.Builder(context) - .setMediaSourceFactory(mediaSourceFactory) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSource)) .build() } + LaunchedEffect(Unit) { runCatching { keyFocus.requestFocus() } } + 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) + s.resumeAtSeconds?.let { + player.setMediaItem(item, it * 1000L) + Toast.makeText( + context, + "Resuming at ${it / 60}:${"%02d".format(it % 60)}", + Toast.LENGTH_LONG, + ).show() + } ?: player.setMediaItem(item) player.prepare() player.playWhenReady = true - onDispose { player.release() } + onDispose { + viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) + player.release() + } } - // Periodic watching report (server-side history). + // Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8). DisposableEffect(player) { var lastReported = 0L + val handler = android.os.Handler(android.os.Looper.getMainLooper()) val runnable = object : Runnable { override fun run() { if (player.isPlaying) { val now = System.currentTimeMillis() val position = player.currentPosition - android.util.Log.d("JTubeWatch", "tick pos=${position}ms sinceLast=${now - lastReported}ms") if (position >= 5_000 && now - lastReported >= 20_000) { - android.util.Log.d("JTubeWatch", "reporting watching at ${position / 1000}s") - viewModel.reportWatching(shortUuid, (position / 1000).toInt()) + viewModel.persistExit(shortUuid, (position / 1000).toInt()) lastReported = now } - } else { - android.util.Log.d("JTubeWatch", "tick not playing") } - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(this, 5_000) + handler.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) - } + handler.postDelayed(runnable, 5_000) + onDispose { handler.removeCallbacks(runnable) } + } + + val handleKey: (android.view.KeyEvent) -> Boolean = { event -> + if (event.action == android.view.KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_CENTER, + android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + -> { + player.playWhenReady = !player.playWhenReady + true + } + android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0)) + true + } + android.view.KeyEvent.KEYCODE_DPAD_RIGHT, android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.seekTo(player.currentPosition + 10_000) + true + } + else -> false // UP/DOWN etc.: show the controller + } + } else false } AndroidView( @@ -201,8 +323,24 @@ private fun PlayerSurface( useController = true this.player = player controllerShowTimeoutMs = 4000 + isFocusable = false + descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS } }, - modifier = Modifier.fillMaxSize().background(Color.Black), + update = { }, + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(keyFocus) + .focusable() + .onPreviewKeyEvent { event -> + handleKey(event.nativeKeyEvent) + }, ) +} + +private fun isInstanceHost(url: String, baseUrl: String?): Boolean { + val urlHost = url.substringAfter("://").substringBefore('/') + val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/') + return baseHost != null && urlHost == baseHost } \ 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 index b0f2614..fc01343 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -19,6 +19,9 @@ 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.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow @@ -38,6 +41,7 @@ fun SetupScreen( val password = rememberSaveable { mutableStateOf("") } val showPassword = rememberSaveable { mutableStateOf(false) } val clipboard = LocalClipboardManager.current + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val busy = loggingIn.collectAsState() val message = error.collectAsState() val localError = rememberSaveable { mutableStateOf(null) } @@ -63,7 +67,15 @@ fun SetupScreen( onValueChange = { url.value = it }, label = { Text("PeerTube instance URL") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) TextField( @@ -71,7 +83,15 @@ fun SetupScreen( onValueChange = { username.value = it }, label = { Text("Username") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) TextField( @@ -82,7 +102,15 @@ fun SetupScreen( visualTransformation = if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + } + .fillMaxWidth(), colors = fieldColors(), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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 index 5eaa1c1..8d67b29 100644 --- 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 @@ -175,7 +175,7 @@ internal fun formatAge(iso: String?): String { val age = Duration.between(Instant.parse(iso), Instant.now()) val days = age.toDays() when { - days >= 365 -> "${days / 365}y ago".replace("0y ago", "just now") + days >= 365 -> "${days / 365}y ago" days >= 30 -> "${days / 30}mo ago" days >= 1 -> "${days}d ago" age.toHours() >= 1 -> "${age.toHours()}h ago" 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 index 3bb45e0..6271151 100644 --- 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 @@ -24,6 +24,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -38,17 +41,35 @@ import net.jeena.jtubetv.ui.list.VideoListViewModel fun VideoListScreen( source: ListSource, onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, viewModel: VideoListViewModel = viewModel( key = sourceKey(source), factory = VideoListViewModel.factory(source), ), ) { val items = viewModel.items + // per-item focus requesters so BACK from the player can refocus the opened card + val requesters = remember(items.size) { + androidx.compose.runtime.mutableStateMapOf() + } // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } + // Which card was opened last — re-focus it when playback ends (UX report BUG-3). + var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } } + LaunchedEffect(restoreToken) { + if (restoreToken > 0) { + if (source is ListSource.History || source is ListSource.MyVideos) { + viewModel.refresh() + } + // focus the card that was playing; fall back to the first card + val target = requesters[lastOpenedIndex] ?: initialFocus + runCatching { target.requestFocus() } + lastOpenedIndex = -1 + } + } // Scroll position survives destination switches (per source), not just player exits. val gridState = androidx.compose.runtime.saveable.rememberSaveable( key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, @@ -106,11 +127,15 @@ fun VideoListScreen( is VideoListItem.Video -> item.video else -> return@itemsIndexed } + val fr = remember { FocusRequester() } + requesters[index] = fr VideoCard( video = video, - onClick = { onOpenVideo(video) }, + onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, - modifier = if (index == 0) Modifier.focusRequester(initialFocus) else Modifier, + modifier = Modifier + .focusRequester(fr) + .also { if (index == 0) Modifier.focusRequester(initialFocus) }, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -129,13 +154,28 @@ fun VideoListScreen( @Composable private fun SearchHeader(viewModel: VideoListViewModel) { var text by remember { mutableStateOf(viewModel.query.value) } + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current Column(Modifier.fillMaxWidth().padding(16.dp)) { TextField( value = text, onValueChange = { text = it }, - label = { Text("Search local videos") }, + label = { Text("Type a search term, then press the search action") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Search, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onSearch = { viewModel.triggerSearch(text); focusManager.clearFocus() }, + ), + modifier = Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ke -> + when (ke.key) { + Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } + Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + else -> false + } + }, ) Button( onClick = { viewModel.triggerSearch(text) }, From 14cd3d397652db323b7bcb4f1755758caa931078 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 12:41:55 +0000 Subject: [PATCH 09/53] ui: Rebuild all screens when the session epoch changes Signing out and logging in as another account kept showing the previous account's videos: the list ViewModels live in the activity store and are keyed by destination only. A session epoch now increments on logout and on each successful login; every ViewModel key, grid scroll state and per-item focus map is keyed by it, so an account switch starts from genuinely fresh data. --- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 9 +++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 3 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 3 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 15 +++++++- .../jeena/jtubetv/ui/screens/SetupScreen.kt | 38 +++++++++++++++---- .../ui/screens/list/VideoListScreen.kt | 10 +++-- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 247057d..e6e542f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,13 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _sessionState + /** + * Bumped on every logout and successful login: all list ViewModels and saved + * UI state are keyed by it, so switching accounts never shows the previous + * account's cached content. + */ + val sessionEpoch = MutableStateFlow(0) + val loginError = MutableStateFlow(null) val loggingIn = MutableStateFlow(false) @@ -35,6 +42,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit loggingIn.value = false if (result.isSuccess) { _sessionState.value = SessionState.Ready + sessionEpoch.value += 1 } else { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" @@ -60,5 +68,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit fun logout() { repository.signOut() _sessionState.value = SessionState.NeedsSetup + sessionEpoch.value += 1 } } \ 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 index c0a4d3f..dca0a69 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -65,7 +65,8 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel Unit, - viewModel: ChannelsViewModel = viewModel(factory = ChannelsViewModel.factory), + epoch: Int = 0, + viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.loadMore(force = true) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a884dc0..a4dc174 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -124,7 +124,8 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, - viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), + epoch: Int = 0, + viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } 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 index 18325e9..9bcf9a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.PersonalVideo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,6 +48,7 @@ enum class Destination(val title: String) { * focused card survive BACK (couch users don't want to re-navigate from the top). */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { + val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID @@ -64,6 +66,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } Box(modifier = Modifier.fillMaxSize()) { + androidx.compose.runtime.key(epoch) { if (destination == Destination.Channels && channelDetails != null) { androidx.compose.foundation.layout.Box( modifier = Modifier @@ -75,6 +78,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi source = ListSource.Channel(channelDetails!!, channelDetails!!), onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) } } else { @@ -133,33 +137,41 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi ) { when (destination) { Destination.Home -> - HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) + HomeRowsScreen( + onOpenVideo = { playingVideo = it.shortUUID }, + epoch = epoch, + ) Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, + epoch = epoch, ) Destination.Search -> VideoListScreen( source = ListSource.Search, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.Subscriptions -> VideoListScreen( source = ListSource.Subscriptions, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.History -> VideoListScreen( source = ListSource.History, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) } } @@ -172,6 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } + } } } 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 index fc01343..3f4fc88 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -16,9 +16,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -41,7 +44,12 @@ fun SetupScreen( val password = rememberSaveable { mutableStateOf("") } val showPassword = rememberSaveable { mutableStateOf(false) } val clipboard = LocalClipboardManager.current - val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + // Deterministic D-pad chain: DOWN from URL goes to Username, not Password + // (geometric focus search misfired on device). + val urlFocus = remember { FocusRequester() } + val userFocus = remember { FocusRequester() } + val passFocus = remember { FocusRequester() } + val signInFocus = remember { FocusRequester() } val busy = loggingIn.collectAsState() val message = error.collectAsState() val localError = rememberSaveable { mutableStateOf(null) } @@ -67,11 +75,17 @@ fun SetupScreen( onValueChange = { url.value = it }, label = { Text("PeerTube instance URL") }, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Next, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onNext = { userFocus.requestFocus() }, + ), modifier = Modifier + .focusRequester(urlFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionDown -> { userFocus.requestFocus(); true } else -> false } } @@ -83,11 +97,18 @@ fun SetupScreen( onValueChange = { username.value = it }, label = { Text("Username") }, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Next, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onNext = { passFocus.requestFocus() }, + ), modifier = Modifier + .focusRequester(userFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionDown -> { passFocus.requestFocus(); true } + Key.DirectionUp -> { urlFocus.requestFocus(); true } else -> false } } @@ -102,11 +123,14 @@ fun SetupScreen( visualTransformation = if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Done, + ), modifier = Modifier + .focusRequester(passFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionUp -> { userFocus.requestFocus(); true } else -> false } } 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 index 6271151..7adcbce 100644 --- 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 @@ -42,20 +42,21 @@ fun VideoListScreen( source: ListSource, onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, + epoch: Int = 0, viewModel: VideoListViewModel = viewModel( - key = sourceKey(source), + key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), ), ) { val items = viewModel.items // per-item focus requesters so BACK from the player can refocus the opened card - val requesters = remember(items.size) { + val requesters = androidx.compose.runtime.remember(items.size, epoch) { androidx.compose.runtime.mutableStateMapOf() } // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } // Which card was opened last — re-focus it when playback ends (UX report BUG-3). - var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) } + var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } } @@ -72,7 +73,8 @@ fun VideoListScreen( } // Scroll position survives destination switches (per source), not just player exits. val gridState = androidx.compose.runtime.saveable.rememberSaveable( - key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + inputs = arrayOf(epoch), + key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } when (source) { From c84ff7bc7bfbfcabaffaf8cfb903e010bf8bb599 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 12:41:55 +0000 Subject: [PATCH 10/53] ui: Rebuild all screens when the session epoch changes Signing out and logging in as another account kept showing the previous account's videos: the list ViewModels live in the activity store and are keyed by destination only. A session epoch now increments on logout and on each successful login; every ViewModel key, grid scroll state and per-item focus map is keyed by it, so an account switch starts from genuinely fresh data. --- .../java/net/jeena/jtubetv/ui/AppViewModel.kt | 9 +++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 3 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 3 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 15 +++++++- .../jeena/jtubetv/ui/screens/SetupScreen.kt | 38 +++++++++++++++---- .../ui/screens/list/VideoListScreen.kt | 10 +++-- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt index 247057d..e6e542f 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/AppViewModel.kt @@ -19,6 +19,13 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit private val _sessionState = MutableStateFlow(SessionState.Loading) val sessionState: StateFlow = _sessionState + /** + * Bumped on every logout and successful login: all list ViewModels and saved + * UI state are keyed by it, so switching accounts never shows the previous + * account's cached content. + */ + val sessionEpoch = MutableStateFlow(0) + val loginError = MutableStateFlow(null) val loggingIn = MutableStateFlow(false) @@ -35,6 +42,7 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit loggingIn.value = false if (result.isSuccess) { _sessionState.value = SessionState.Ready + sessionEpoch.value += 1 } else { loginError.value = if (result.exceptionOrNull() is PeerTubeRepository.BadCredentialsException) { "Invalid credentials" @@ -60,5 +68,6 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit fun logout() { repository.signOut() _sessionState.value = SessionState.NeedsSetup + sessionEpoch.value += 1 } } \ 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 index c0a4d3f..dca0a69 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -65,7 +65,8 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel Unit, - viewModel: ChannelsViewModel = viewModel(factory = ChannelsViewModel.factory), + epoch: Int = 0, + viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.loadMore(force = true) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a884dc0..a4dc174 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -124,7 +124,8 @@ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedL fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, - viewModel: HomeRowsViewModel = viewModel(factory = HomeRowsViewModel.factory), + epoch: Int = 0, + viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } 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 index 18325e9..9bcf9a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.PersonalVideo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,6 +48,7 @@ enum class Destination(val title: String) { * focused card survive BACK (couch users don't want to re-navigate from the top). */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { + val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID @@ -64,6 +66,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } Box(modifier = Modifier.fillMaxSize()) { + androidx.compose.runtime.key(epoch) { if (destination == Destination.Channels && channelDetails != null) { androidx.compose.foundation.layout.Box( modifier = Modifier @@ -75,6 +78,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi source = ListSource.Channel(channelDetails!!, channelDetails!!), onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) } } else { @@ -133,33 +137,41 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi ) { when (destination) { Destination.Home -> - HomeRowsScreen(onOpenVideo = { playingVideo = it.shortUUID }) + HomeRowsScreen( + onOpenVideo = { playingVideo = it.shortUUID }, + epoch = epoch, + ) Destination.MyVideos -> VideoListScreen( source = ListSource.MyVideos, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, + epoch = epoch, ) Destination.Search -> VideoListScreen( source = ListSource.Search, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.Subscriptions -> VideoListScreen( source = ListSource.Subscriptions, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) Destination.History -> VideoListScreen( source = ListSource.History, onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, + epoch = epoch, ) } } @@ -172,6 +184,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } + } } } 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 index fc01343..3f4fc88 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -16,9 +16,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -41,7 +44,12 @@ fun SetupScreen( val password = rememberSaveable { mutableStateOf("") } val showPassword = rememberSaveable { mutableStateOf(false) } val clipboard = LocalClipboardManager.current - val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + // Deterministic D-pad chain: DOWN from URL goes to Username, not Password + // (geometric focus search misfired on device). + val urlFocus = remember { FocusRequester() } + val userFocus = remember { FocusRequester() } + val passFocus = remember { FocusRequester() } + val signInFocus = remember { FocusRequester() } val busy = loggingIn.collectAsState() val message = error.collectAsState() val localError = rememberSaveable { mutableStateOf(null) } @@ -67,11 +75,17 @@ fun SetupScreen( onValueChange = { url.value = it }, label = { Text("PeerTube instance URL") }, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Next, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onNext = { userFocus.requestFocus() }, + ), modifier = Modifier + .focusRequester(urlFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionDown -> { userFocus.requestFocus(); true } else -> false } } @@ -83,11 +97,18 @@ fun SetupScreen( onValueChange = { username.value = it }, label = { Text("Username") }, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Next, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onNext = { passFocus.requestFocus() }, + ), modifier = Modifier + .focusRequester(userFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionDown -> { passFocus.requestFocus(); true } + Key.DirectionUp -> { urlFocus.requestFocus(); true } else -> false } } @@ -102,11 +123,14 @@ fun SetupScreen( visualTransformation = if (showPassword.value) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Done, + ), modifier = Modifier + .focusRequester(passFocus) .onPreviewKeyEvent { ke -> when (ke.key) { - Key.DirectionDown -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true } - Key.DirectionUp -> { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true } + Key.DirectionUp -> { userFocus.requestFocus(); true } else -> false } } 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 index 6271151..7adcbce 100644 --- 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 @@ -42,20 +42,21 @@ fun VideoListScreen( source: ListSource, onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, + epoch: Int = 0, viewModel: VideoListViewModel = viewModel( - key = sourceKey(source), + key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), ), ) { val items = viewModel.items // per-item focus requesters so BACK from the player can refocus the opened card - val requesters = remember(items.size) { + val requesters = androidx.compose.runtime.remember(items.size, epoch) { androidx.compose.runtime.mutableStateMapOf() } // Pull initial focus out of the sidebar rail and into the content grid. val initialFocus = remember { FocusRequester() } // Which card was opened last — re-focus it when playback ends (UX report BUG-3). - var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(-1) } + var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } } @@ -72,7 +73,8 @@ fun VideoListScreen( } // Scroll position survives destination switches (per source), not just player exits. val gridState = androidx.compose.runtime.saveable.rememberSaveable( - key = sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, + inputs = arrayOf(epoch), + key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver, ) { androidx.compose.foundation.lazy.grid.LazyGridState() } when (source) { From 6cd8417c7dd714a8388c03a7e70a7675a26d4e04 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:16:29 +0000 Subject: [PATCH 11/53] ui: SmartTube layout - hidden rail, top bar, white focus cards Drop the always-visible sidebar: content is now full width so ~4.5 compact cards fit per row (width derived from the screen). A slide-in menu drawer opens via the avatar (BACK/LEFT close it) and carries the destinations + sign out with legible labels (#B0B0B0) replacing the illegible dark-grey rail. Cards no longer scale on focus: they flip to a white background with dark text and a white frame like SmartTube. Grids move to 3 columns with the freed width. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 13 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 307 +++++++++++++----- .../jtubetv/ui/screens/list/VideoCard.kt | 40 ++- .../ui/screens/list/VideoListScreen.kt | 2 +- smarttube.jpg | Bin 0 -> 283303 bytes 5 files changed, 268 insertions(+), 94 deletions(-) create mode 100644 smarttube.jpg diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a4dc174..b0f5c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -36,7 +36,12 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.screens.list.VideoCard -private val ROW_CARD_WIDTH = 296.dp +/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ +@Composable +private fun rowCardWidth(): androidx.compose.ui.unit.Dp { + val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp + return ((windowWidth / 4.35f) - 10).dp +} /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ data class ContinueItem(val video: VideoDto, val progress: Float?) @@ -157,9 +162,10 @@ fun HomeRowsScreen( VideoCard( video = item.video, onClick = { onOpenVideo(item.video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, progress = item.progress, + compact = true, ) } } @@ -192,8 +198,9 @@ fun HomeRowsScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, + compact = true, ) if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } 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 index 9bcf9a6..c4dd2d8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -1,38 +1,51 @@ package net.jeena.jtubetv.ui.screens +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape 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.Logout -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.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subscriptions +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput 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.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen -import androidx.activity.compose.BackHandler enum class Destination(val title: String) { Home("Home"), @@ -43,9 +56,12 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. The player renders as a - * fullscreen overlay: the list beneath stays composed, so its scroll position and - * focused card survive BACK (couch users don't want to re-navigate from the top). */ +/** + * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, + * full-width rows/list content. The menu drawer slides in when wanted (avatar or + * the search/BACK flows) and everything is key(epoch) so an account switch + * rebuilds all state. + */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() @@ -53,6 +69,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends + var drawerOpen by rememberSaveable { mutableStateOf(false) } + val context = androidx.compose.ui.platform.LocalContext.current + var lastBackAt by rememberSaveable { mutableStateOf(0L) } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -60,85 +79,42 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // Channel drilldown hides the sidebar; remote BACK must return to the channels list. - BackHandler(enabled = playingVideo == null && channelDetails != null) { + // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. + BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { + val now = System.currentTimeMillis() + if (now - lastBackAt < 2000) { + (context as? android.app.Activity)?.finish() + } else { + lastBackAt = now + android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() + } + } + BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { channelDetails = null } + BackHandler(enabled = drawerOpen && playingVideo == null) { + drawerOpen = false + } Box(modifier = Modifier.fillMaxSize()) { androidx.compose.runtime.key(epoch) { - if (destination == Destination.Channels && channelDetails != null) { - androidx.compose.foundation.layout.Box( + Column { + // SmartTube-style top bar: red round search + avatar opening the menu. + TopBar( + onSearchClick = { destination = Destination.Search; channelDetails = null }, + onAccountClick = { drawerOpen = !drawerOpen }, + ) + Box( modifier = Modifier .fillMaxSize() - // TV overscan safety margins (list content, not the player). - .padding(horizontal = 32.dp), + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), ) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - restoreToken = restoreToken, - epoch = epoch, - ) - } - } else { - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - color = androidx.compose.ui.graphics.Color.White, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, - ) - } - val signOutContext = androidx.compose.ui.platform.LocalContext.current - var lastSignOutTap by remember { mutableStateOf(0L) } - NavigationDrawerItem( - selected = false, - onClick = { - val now = System.currentTimeMillis() - if (now - lastSignOutTap < 3000) { - viewModel.logout() - } else { - lastSignOutTap = now - android.widget.Toast.makeText( - signOutContext, - "Press again to sign out", - android.widget.Toast.LENGTH_SHORT, - ).show() - } - }, - leadingContent = { - Icon(imageVector = Icons.Filled.Logout, contentDescription = null) - }, - content = { Text("Sign out") }, - ) - } - }) { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 16.dp), - ) { when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, epoch = epoch, ) Destination.MyVideos -> @@ -174,9 +150,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi epoch = epoch, ) } - } } } + } + + // Slide-in menu drawer (SmartTube style: hidden until wanted). + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { drawerOpen = false }, + ) + } if (playingVideo != null) { PlayerScreen( @@ -184,11 +174,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } - } } } -private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = +private fun Destination.icon(): ImageVector = when (this) { Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo @@ -196,4 +185,158 @@ private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector Destination.Search -> Icons.Filled.Search Destination.Subscriptions -> Icons.Filled.Subscriptions Destination.History -> Icons.Filled.History - } \ No newline at end of file + } + +/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +@Composable +private fun TopBar( + onSearchClick: () -> Unit, + onAccountClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + ) { + val searchFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFFCE2B2B)) + .focusRequester(searchFocus) + .focusable() + .clickable(onClick = onSearchClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search", + tint = Color.White, + ) + } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)) + .focusable() + .clickable(onClick = onAccountClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.PersonalVideo, + contentDescription = "Menu", + tint = Color(0xFFCCCCCC), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "JTube TV", + color = Color(0xFF9A9A9A), + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } +} + +/** + * Slide-in menu drawer: destination list + sign out, readable on a dark background + * (UX report: unselected grey was illegible). Selected item gets the red accent. + */ +@Composable +private fun MenuDrawer( + selected: Destination, + onSelect: (Destination) -> Unit, + onSignOut: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val drawerFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { drawerFocus.requestFocus() } } + val signOutContext = androidx.compose.ui.platform.LocalContext.current + var lastSignOutTap by remember { mutableStateOf(0L) } + Row(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .width(240.dp) + .fillMaxSize() + .background(Color(0xFF111111)) + .padding(vertical = 24.dp), + ) { + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), + ) + Destination.entries.forEachIndexed { index, dest -> + MenuDrawerItem( + icon = dest.icon(), + label = dest.title, + selected = dest == selected, + onClick = { onSelect(dest) }, + modifier = if (index == 0) Modifier.focusRequester(drawerFocus) else Modifier, + ) + } + Spacer(modifier = Modifier.weight(1f)) + MenuDrawerItem( + icon = Icons.Filled.Logout, + label = "Sign out", + selected = false, + onClick = { + if (System.currentTimeMillis() - lastSignOutTap < 3000) { + onSignOut() + } else { + lastSignOutTap = System.currentTimeMillis() + android.widget.Toast.makeText( + signOutContext, + "Press again to sign out", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + }, + modifier = Modifier, + ) + } + // tap/click outside closes; focus LEFT from the drawer also closes + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .focusable() + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + ) + } +} + +@Composable +private fun MenuDrawerItem( + icon: ImageVector, + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val labelColor = if (selected) Color(0xFFE0E0E0) else Color(0xFFB0B0B0) + Row( + modifier = modifier + .fillMaxWidth() + .then(if (selected) Modifier.background(Color(0x33CE2B2B)) else Modifier) + .focusable() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), + ) { + Icon(imageVector = icon, contentDescription = null, tint = labelColor) + Text( + text = label, + color = labelColor, + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + ) + } +} \ 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 index 8d67b29..7b99671 100644 --- 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 @@ -3,6 +3,7 @@ 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.aspectRatio import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,8 @@ 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.compose.foundation.BorderStroke +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage @@ -43,19 +46,35 @@ fun VideoCard( modifier: Modifier = Modifier, showPrivacy: Boolean = true, progress: Float? = null, + /** Compact = row in the SmartTube home: single-line title, smaller. */ + compact: Boolean = false, ) { Card( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(268.dp) - .padding(6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + modifier = modifier.padding(6.dp), + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + // SmartTube look: focused card turns white with dark text, no size change. + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + pressedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + ), ) { Box( modifier = Modifier .fillMaxWidth() - .height(158.dp) + .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> @@ -105,10 +124,11 @@ fun VideoCard( Text( text = video.name, style = MaterialTheme.typography.titleSmall, - maxLines = 2, - minLines = 2, + maxLines = if (compact) 1 else 2, + minLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, ) + Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, @@ -161,6 +181,10 @@ internal fun formatDuration(seconds: Int): String { } } +/** Card palette (SmartTube-style): near-black idle, white + dark text on focus. */ +internal val UnfocusedCardBg = Color(0xFF171717) +internal val ContainerShape = RoundedCornerShape(10.dp) + internal fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0) 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 index 7adcbce..8c41235 100644 --- 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 @@ -119,7 +119,7 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { diff --git a/smarttube.jpg b/smarttube.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae51311c4ed2c2c22ed2c10d4b72c51869151b32 GIT binary patch literal 283303 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 =Q6=$(&hHoX4;kF-2a9pfzBD*|b!#>WwI%PZRB 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μF=Buq$#%i(U9Sw~V;#&)*JDHl^#SSsZ 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%0AF|;8ZQe@_*;NTJ3lv6mUH+P z{{R)(aGbn3$NUw>P`iWlEe>oLaf~^bQ1i(de2xXyciWe-7A<`I&~1A+^n1!{S;nwA z&T~IRH!bb#d?`Wy0NSs`I&0s_jLX*N{oMI)ZWhOnGThrWXR2joXO2xvF@RSj@;1FZ z#bUdvomLZ%n=B`(Yk1wsMW=ft1*YLkg=o5_k`w_z=$}*|Z7aO^ImOsq7sjz_x|}o3 zgzR~7r7!?r7=0da2^Uqk^ga461xy;~th!A;$;9}Fpyy#lnS-NiGr==?;(2Ur4;R<_ zOCM08{{V;>%;v5b&IE4X6DlAN=4pI`9#%wghDDJ(7(ZpQ5C_mG$u!ammT6u+9P(F| z@J;SX{*SGPKSz!;y9}-VZ>?wep4J25XnIs<)_p)b_cQpc8GbG~g~Pz%$g6KQTaA4v zFO#r0IK?ae~l8f(8}Rb3FZ_eHV+CJzi+<4iSupflZO=k*y?AiC;3NW{yWSB7_`kLq;$9okMC4T z#~Q)jFR;RUhGRCrJ6x!6wJQhFYH?%sX8teL-|w?^omVqio;mdy{8zD@jixn#3%-M8 zuj;0HQb19)xg_x)KbMz2T&{WD+NNE?q%7R02ecNqlvhQGNah0U{v7A&IC5%#rSgt9 ziyUGqh4OPzad+JYyLDV`E}MdI-WycIv-Iv9;^MD^FB*nqfO4Db3?`@9$r_pq{?mPDTjd#HvWJ>Iv0%s0sLCV87 z)!=#KA2yC*{%e=8;^)N+IVX{aeY&oadd#?-e z-kZcYjDuN{EDcZY$+%=QnkSuv{nBf8ov)`_8^xS6)YW=l+5=uAKY!PtSGraPSU2cF zT%`IwbX>n=&g2g0_e^z!+deqz2#{Cs@aW8{8ald29cKOUluzB z?{XXJI@0VV*3O0_%YsJu%>XtlqbUCG$$6e8Yw+Cf40(@o7T-j8$m%OoYc zNZ#Yv`W4JDcB)`I69fE`y6 zrfad|K`W+>?2VvF;#D@fe9zD=q>s$Y$8+;$IItS5W?Nq)dl@@`N9_JSi?$ZWEb*7h zTt@s1#yc8TL6ZhNt(rpr05eRCzEB5n2_&Ct-*u(XxY@cC4#LaDenXlZAaD(DI0DBr z%e`*)*=87n-a}(+SUW!0B`EJ{I;_L3z8xLHr6xyzh3mQarJrdWqa`P8x~wobaTa$ zJWgM2s=kU^j-xKFSu@!TE{t-B*-#XC6fAwr9~XLg!zNH zsFUC1wiwuAX8^+)C;P-~wb-o7WMPm+VD1|mY@dHsyu{P;Cv0rU#&mCRusOqdN3Dys z^LmWFOzv~)?S`XKtjRtWn=5faZe~-?7At|R&)$$8zE#8Qfes%WQ5*p|@ICS$dYOr{qT-tEZeOtHki8hDfC> z>1Uh(YuGMV9HjK?jPAO{yKWqgE_0e(-fmm$PpA5o+Kokv5+WqlbZzKUmig(Ah@RL2y2Al`S8{Y)rRso_yKboS7aEA>}r2 z%mCc`k=by#a$L9cb;mwAjh|N;hozXXq?PkFS-H36Jire^R#qgujs%(oWpLCDc87}# zTgW^jUO?(kQnf;Pv89$Mh%Dx#W2*I8rGbL{S?sE%sC+;hm(5WdIecFm1lg1oxLe-vh=2m2WDmD!H0&^O_p*$SVvZ|yBD99 z-s?w~#ClMew-OyzeqFpt8605OpakvnOEkV(@rVYZ=rXb)fE(p}≧XwWqFPO%XE) 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 Date: Sat, 10 Oct 2026 22:16:29 +0000 Subject: [PATCH 12/53] ui: SmartTube layout - hidden rail, top bar, white focus cards Drop the always-visible sidebar: content is now full width so ~4.5 compact cards fit per row (width derived from the screen). A slide-in menu drawer opens via the avatar (BACK/LEFT close it) and carries the destinations + sign out with legible labels (#B0B0B0) replacing the illegible dark-grey rail. Cards no longer scale on focus: they flip to a white background with dark text and a white frame like SmartTube. Grids move to 3 columns with the freed width. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 13 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 307 +++++++++++++----- .../jtubetv/ui/screens/list/VideoCard.kt | 40 ++- .../ui/screens/list/VideoListScreen.kt | 2 +- smarttube.jpg | Bin 0 -> 283303 bytes 5 files changed, 268 insertions(+), 94 deletions(-) create mode 100644 smarttube.jpg diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a4dc174..b0f5c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -36,7 +36,12 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.screens.list.VideoCard -private val ROW_CARD_WIDTH = 296.dp +/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ +@Composable +private fun rowCardWidth(): androidx.compose.ui.unit.Dp { + val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp + return ((windowWidth / 4.35f) - 10).dp +} /** One "Continue watching" candidate: video + watched fraction (null = unknown yet). */ data class ContinueItem(val video: VideoDto, val progress: Float?) @@ -157,9 +162,10 @@ fun HomeRowsScreen( VideoCard( video = item.video, onClick = { onOpenVideo(item.video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, progress = item.progress, + compact = true, ) } } @@ -192,8 +198,9 @@ fun HomeRowsScreen( VideoCard( video = video, onClick = { onOpenVideo(video) }, - modifier = Modifier.width(ROW_CARD_WIDTH), + modifier = Modifier.width(rowCardWidth()), showPrivacy = false, + compact = true, ) if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } 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 index 9bcf9a6..c4dd2d8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -1,38 +1,51 @@ package net.jeena.jtubetv.ui.screens +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape 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.Logout -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.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subscriptions +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput 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.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen -import androidx.activity.compose.BackHandler enum class Destination(val title: String) { Home("Home"), @@ -43,9 +56,12 @@ enum class Destination(val title: String) { History("History"), } -/** Home = sidebar (D-pad navigable) + content pane. The player renders as a - * fullscreen overlay: the list beneath stays composed, so its scroll position and - * focused card survive BACK (couch users don't want to re-navigate from the top). */ +/** + * Home = SmartTube-style: hidden menu, red round search + avatar in a top bar, + * full-width rows/list content. The menu drawer slides in when wanted (avatar or + * the search/BACK flows) and everything is key(epoch) so an account switch + * rebuilds all state. + */ @Composable fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { val epoch by viewModel.sessionEpoch.collectAsState() @@ -53,6 +69,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var channelDetails by rememberSaveable { mutableStateOf(null) } // handle var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends + var drawerOpen by rememberSaveable { mutableStateOf(false) } + val context = androidx.compose.ui.platform.LocalContext.current + var lastBackAt by rememberSaveable { mutableStateOf(0L) } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -60,85 +79,42 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // Channel drilldown hides the sidebar; remote BACK must return to the channels list. - BackHandler(enabled = playingVideo == null && channelDetails != null) { + // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. + BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { + val now = System.currentTimeMillis() + if (now - lastBackAt < 2000) { + (context as? android.app.Activity)?.finish() + } else { + lastBackAt = now + android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() + } + } + BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { channelDetails = null } + BackHandler(enabled = drawerOpen && playingVideo == null) { + drawerOpen = false + } Box(modifier = Modifier.fillMaxSize()) { androidx.compose.runtime.key(epoch) { - if (destination == Destination.Channels && channelDetails != null) { - androidx.compose.foundation.layout.Box( + Column { + // SmartTube-style top bar: red round search + avatar opening the menu. + TopBar( + onSearchClick = { destination = Destination.Search; channelDetails = null }, + onAccountClick = { drawerOpen = !drawerOpen }, + ) + Box( modifier = Modifier .fillMaxSize() - // TV overscan safety margins (list content, not the player). - .padding(horizontal = 32.dp), + // TV overscan safety margins on the content pane. + .padding(start = 8.dp, end = 32.dp, top = 8.dp), ) { - VideoListScreen( - source = ListSource.Channel(channelDetails!!, channelDetails!!), - onOpenVideo = { playingVideo = it.shortUUID }, - restoreToken = restoreToken, - epoch = epoch, - ) - } - } else { - NavigationDrawer(drawerContent = { - androidx.compose.foundation.layout.Column( - modifier = Modifier.width(220.dp).padding(vertical = 16.dp), - ) { - Text( - text = "JTube TV", - modifier = Modifier.padding(start = 28.dp, bottom = 16.dp), - style = androidx.tv.material3.MaterialTheme.typography.headlineSmall, - color = androidx.compose.ui.graphics.Color.White, - ) - Destination.entries.forEach { dest -> - NavigationDrawerItem( - selected = dest == destination, - onClick = { - destination = dest - channelDetails = null - }, - leadingContent = { - Icon(imageVector = dest.icon(), contentDescription = null) - }, - content = { Text(dest.title) }, - ) - } - val signOutContext = androidx.compose.ui.platform.LocalContext.current - var lastSignOutTap by remember { mutableStateOf(0L) } - NavigationDrawerItem( - selected = false, - onClick = { - val now = System.currentTimeMillis() - if (now - lastSignOutTap < 3000) { - viewModel.logout() - } else { - lastSignOutTap = now - android.widget.Toast.makeText( - signOutContext, - "Press again to sign out", - android.widget.Toast.LENGTH_SHORT, - ).show() - } - }, - leadingContent = { - Icon(imageVector = Icons.Filled.Logout, contentDescription = null) - }, - content = { Text("Sign out") }, - ) - } - }) { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .fillMaxSize() - // TV overscan safety margins on the content pane. - .padding(start = 8.dp, end = 32.dp, top = 16.dp), - ) { when (destination) { Destination.Home -> HomeRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, + restoreToken = restoreToken, epoch = epoch, ) Destination.MyVideos -> @@ -174,9 +150,23 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi epoch = epoch, ) } - } } } + } + + // Slide-in menu drawer (SmartTube style: hidden until wanted). + if (drawerOpen) { + MenuDrawer( + selected = destination, + onSelect = { + destination = it + channelDetails = null + drawerOpen = false + }, + onSignOut = { viewModel.logout() }, + onDismiss = { drawerOpen = false }, + ) + } if (playingVideo != null) { PlayerScreen( @@ -184,11 +174,10 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onBack = { playingVideo = null }, ) } - } } } -private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector = +private fun Destination.icon(): ImageVector = when (this) { Destination.Home -> Icons.Filled.VideoLibrary Destination.MyVideos -> Icons.Filled.PersonalVideo @@ -196,4 +185,158 @@ private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector Destination.Search -> Icons.Filled.Search Destination.Subscriptions -> Icons.Filled.Subscriptions Destination.History -> Icons.Filled.History - } \ No newline at end of file + } + +/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */ +@Composable +private fun TopBar( + onSearchClick: () -> Unit, + onAccountClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 10.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), + ) { + val searchFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFFCE2B2B)) + .focusRequester(searchFocus) + .focusable() + .clickable(onClick = onSearchClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search", + tint = Color.White, + ) + } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)) + .focusable() + .clickable(onClick = onAccountClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.PersonalVideo, + contentDescription = "Menu", + tint = Color(0xFFCCCCCC), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "JTube TV", + color = Color(0xFF9A9A9A), + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } +} + +/** + * Slide-in menu drawer: destination list + sign out, readable on a dark background + * (UX report: unselected grey was illegible). Selected item gets the red accent. + */ +@Composable +private fun MenuDrawer( + selected: Destination, + onSelect: (Destination) -> Unit, + onSignOut: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val drawerFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { drawerFocus.requestFocus() } } + val signOutContext = androidx.compose.ui.platform.LocalContext.current + var lastSignOutTap by remember { mutableStateOf(0L) } + Row(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .width(240.dp) + .fillMaxSize() + .background(Color(0xFF111111)) + .padding(vertical = 24.dp), + ) { + Text( + text = "JTube TV", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 28.dp, bottom = 20.dp), + ) + Destination.entries.forEachIndexed { index, dest -> + MenuDrawerItem( + icon = dest.icon(), + label = dest.title, + selected = dest == selected, + onClick = { onSelect(dest) }, + modifier = if (index == 0) Modifier.focusRequester(drawerFocus) else Modifier, + ) + } + Spacer(modifier = Modifier.weight(1f)) + MenuDrawerItem( + icon = Icons.Filled.Logout, + label = "Sign out", + selected = false, + onClick = { + if (System.currentTimeMillis() - lastSignOutTap < 3000) { + onSignOut() + } else { + lastSignOutTap = System.currentTimeMillis() + android.widget.Toast.makeText( + signOutContext, + "Press again to sign out", + android.widget.Toast.LENGTH_SHORT, + ).show() + } + }, + modifier = Modifier, + ) + } + // tap/click outside closes; focus LEFT from the drawer also closes + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .focusable() + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + ) + } +} + +@Composable +private fun MenuDrawerItem( + icon: ImageVector, + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val labelColor = if (selected) Color(0xFFE0E0E0) else Color(0xFFB0B0B0) + Row( + modifier = modifier + .fillMaxWidth() + .then(if (selected) Modifier.background(Color(0x33CE2B2B)) else Modifier) + .focusable() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), + ) { + Icon(imageVector = icon, contentDescription = null, tint = labelColor) + Text( + text = label, + color = labelColor, + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + ) + } +} \ 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 index 8d67b29..7b99671 100644 --- 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 @@ -3,6 +3,7 @@ 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.aspectRatio import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,8 @@ 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.compose.foundation.BorderStroke +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage @@ -43,19 +46,35 @@ fun VideoCard( modifier: Modifier = Modifier, showPrivacy: Boolean = true, progress: Float? = null, + /** Compact = row in the SmartTube home: single-line title, smaller. */ + compact: Boolean = false, ) { Card( onClick = onClick, - modifier = modifier - .fillMaxWidth() - .height(268.dp) - .padding(6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + modifier = modifier.padding(6.dp), + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + // SmartTube look: focused card turns white with dark text, no size change. + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + pressedBorder = Border( + border = BorderStroke(3.dp, Color.White), + shape = ContainerShape, + ), + ), ) { Box( modifier = Modifier .fillMaxWidth() - .height(158.dp) + .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> @@ -105,10 +124,11 @@ fun VideoCard( Text( text = video.name, style = MaterialTheme.typography.titleSmall, - maxLines = 2, - minLines = 2, + maxLines = if (compact) 1 else 2, + minLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, ) + Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, @@ -161,6 +181,10 @@ internal fun formatDuration(seconds: Int): String { } } +/** Card palette (SmartTube-style): near-black idle, white + dark text on focus. */ +internal val UnfocusedCardBg = Color(0xFF171717) +internal val ContainerShape = RoundedCornerShape(10.dp) + internal fun formatViews(views: Long): String = when { views >= 1_000_000 -> "%.1fM views".format(Locale.US, views / 1_000_000.0) 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 index 7adcbce..8c41235 100644 --- 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 @@ -119,7 +119,7 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { diff --git a/smarttube.jpg b/smarttube.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae51311c4ed2c2c22ed2c10d4b72c51869151b32 GIT binary patch literal 283303 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 =Q6=$(&hHoX4;kF-2a9pfzBD*|b!#>WwI%PZRB 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μF=Buq$#%i(U9Sw~V;#&)*JDHl^#SSsZ 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%0AF|;8ZQe@_*;NTJ3lv6mUH+P z{{R)(aGbn3$NUw>P`iWlEe>oLaf~^bQ1i(de2xXyciWe-7A<`I&~1A+^n1!{S;nwA z&T~IRH!bb#d?`Wy0NSs`I&0s_jLX*N{oMI)ZWhOnGThrWXR2joXO2xvF@RSj@;1FZ z#bUdvomLZ%n=B`(Yk1wsMW=ft1*YLkg=o5_k`w_z=$}*|Z7aO^ImOsq7sjz_x|}o3 zgzR~7r7!?r7=0da2^Uqk^ga461xy;~th!A;$;9}Fpyy#lnS-NiGr==?;(2Ur4;R<_ zOCM08{{V;>%;v5b&IE4X6DlAN=4pI`9#%wghDDJ(7(ZpQ5C_mG$u!ammT6u+9P(F| z@J;SX{*SGPKSz!;y9}-VZ>?wep4J25XnIs<)_p)b_cQpc8GbG~g~Pz%$g6KQTaA4v zFO#r0IK?ae~l8f(8}Rb3FZ_eHV+CJzi+<4iSupflZO=k*y?AiC;3NW{yWSB7_`kLq;$9okMC4T z#~Q)jFR;RUhGRCrJ6x!6wJQhFYH?%sX8teL-|w?^omVqio;mdy{8zD@jixn#3%-M8 zuj;0HQb19)xg_x)KbMz2T&{WD+NNE?q%7R02ecNqlvhQGNah0U{v7A&IC5%#rSgt9 ziyUGqh4OPzad+JYyLDV`E}MdI-WycIv-Iv9;^MD^FB*nqfO4Db3?`@9$r_pq{?mPDTjd#HvWJ>Iv0%s0sLCV87 z)!=#KA2yC*{%e=8;^)N+IVX{aeY&oadd#?-e z-kZcYjDuN{EDcZY$+%=QnkSuv{nBf8ov)`_8^xS6)YW=l+5=uAKY!PtSGraPSU2cF zT%`IwbX>n=&g2g0_e^z!+deqz2#{Cs@aW8{8ald29cKOUluzB z?{XXJI@0VV*3O0_%YsJu%>XtlqbUCG$$6e8Yw+Cf40(@o7T-j8$m%OoYc zNZ#Yv`W4JDcB)`I69fE`y6 zrfad|K`W+>?2VvF;#D@fe9zD=q>s$Y$8+;$IItS5W?Nq)dl@@`N9_JSi?$ZWEb*7h zTt@s1#yc8TL6ZhNt(rpr05eRCzEB5n2_&Ct-*u(XxY@cC4#LaDenXlZAaD(DI0DBr z%e`*)*=87n-a}(+SUW!0B`EJ{I;_L3z8xLHr6xyzh3mQarJrdWqa`P8x~wobaTa$ zJWgM2s=kU^j-xKFSu@!TE{t-B*-#XC6fAwr9~XLg!zNH zsFUC1wiwuAX8^+)C;P-~wb-o7WMPm+VD1|mY@dHsyu{P;Cv0rU#&mCRusOqdN3Dys z^LmWFOzv~)?S`XKtjRtWn=5faZe~-?7At|R&)$$8zE#8Qfes%WQ5*p|@ICS$dYOr{qT-tEZeOtHki8hDfC> z>1Uh(YuGMV9HjK?jPAO{yKWqgE_0e(-fmm$PpA5o+Kokv5+WqlbZzKUmig(Ah@RL2y2Al`S8{Y)rRso_yKboS7aEA>}r2 z%mCc`k=by#a$L9cb;mwAjh|N;hozXXq?PkFS-H36Jire^R#qgujs%(oWpLCDc87}# zTgW^jUO?(kQnf;Pv89$Mh%Dx#W2*I8rGbL{S?sE%sC+;hm(5WdIecFm1lg1oxLe-vh=2m2WDmD!H0&^O_p*$SVvZ|yBD99 z-s?w~#ClMew-OyzeqFpt8605OpakvnOEkV(@rVYZ=rXb)fE(p}≧XwWqFPO%XE) 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 Date: Sat, 10 Oct 2026 22:24:06 +0000 Subject: [PATCH 13/53] ui: Eased card-row scrolling, dark text on focused white cards Focused-card rows now animate the newly focused card to the left edge (animateScrollToItem) instead of the abrupt bring-into-view jump - SmartTube-style easing. Text inside a focused (white) card flips to a dark gray-on-white palette instead of staying light gray. LEFT on the first card of a row/chain opens the menu drawer. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 7 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 98 +++++++++++++------ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 6 ++ .../jtubetv/ui/screens/list/VideoCard.kt | 35 ++++++- .../ui/screens/list/VideoListScreen.kt | 5 +- 5 files changed, 113 insertions(+), 38 deletions(-) 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 index dca0a69..26f711a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel Unit, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.loadMore(force = true) } @@ -95,7 +96,7 @@ fun ChannelsScreen( } } } - else -> ChannelsList(items, viewModel, onOpenChannel) + else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer) } } } @@ -105,12 +106,14 @@ private fun ChannelsList( items: List, viewModel: ChannelsViewModel, onOpenChannel: (String) -> Unit, + onOpenDrawer: () -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { itemsIndexed(items) { index, channel -> ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, + onLeftAtStart = if (index == 0) onOpenDrawer else null, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -127,7 +130,7 @@ private fun ChannelsList( } @Composable -private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { +private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) { Card( onClick = onClick, modifier = Modifier diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index b0f5c39..baca2a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -130,6 +133,7 @@ fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } @@ -154,21 +158,14 @@ fun HomeRowsScreen( ) } } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(viewModel.continueItems) { _, item -> - VideoCard( - video = item.video, - onClick = { onOpenVideo(item.video) }, - modifier = Modifier.width(rowCardWidth()), - showPrivacy = false, - progress = item.progress, - compact = true, - ) - } - } + FocusScrollRow( + videos = viewModel.continueItems.map { it.video }, + progressOf = { shortUUID -> + viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress + }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) } } item { @@ -190,24 +187,63 @@ fun HomeRowsScreen( ) } } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(recentItems) { index, video -> - VideoCard( - video = video, - onClick = { onOpenVideo(video) }, - modifier = Modifier.width(rowCardWidth()), - showPrivacy = false, - compact = true, - ) - if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { viewModel.recent.loadMore() } - } + FocusScrollRow( + videos = recentItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) { index, video -> + if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { viewModel.recent.loadMore() } } } } } } -} \ No newline at end of file +} +/** + * Horizontal card row with eased scrolling: when focus moves to a card outside the + * viewport, the row animates it to the left edge (SmartTube behaviour) instead of + * the abrupt jump the built-in lazy scroll produces. + */ +@Composable +private fun FocusScrollRow( + videos: List, + progressOf: (String) -> Float?, + onOpenVideo: (VideoDto) -> Unit, + onLeftAtStart: () -> Unit, + afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, +) { + val rowState = rememberLazyListState() + var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } + LaunchedEffect(focusedIndex) { + if (focusedIndex > 0) { + val visible = rowState.layoutInfo.visibleItemsInfo + val target = visible.firstOrNull { it.index == focusedIndex } + // animate only when the focused card is not (fully) on screen + val notFullyVisible = target == null || + target.offset < 0 || + target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) + } + } + LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(videos) { index, video -> + VideoCard( + video = video, + onClick = { onOpenVideo(video) }, + modifier = Modifier.width(rowCardWidth()), + showPrivacy = false, + compact = true, + progress = progressOf(video.shortUUID), + onLeftAtStart = if (index == 0) onLeftAtStart else null, + onFocused = { focusedIndex = index }, + ) + afterItem(index, video) + } + } +} 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 index c4dd2d8..18af7ea 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -116,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.MyVideos -> VideoListScreen( @@ -123,10 +124,12 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Search -> VideoListScreen( @@ -134,6 +137,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Subscriptions -> VideoListScreen( @@ -141,6 +145,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.History -> VideoListScreen( @@ -148,6 +153,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) } } 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 index 7b99671..5c33ea0 100644 --- 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 @@ -13,9 +13,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow @@ -48,10 +57,29 @@ fun VideoCard( progress: Float? = null, /** Compact = row in the SmartTube home: single-line title, smaller. */ compact: Boolean = false, + /** LEFT (only on the row's first card) can open the menu drawer. */ + onLeftAtStart: (() -> Unit)? = null, + /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ + onFocused: (() -> Unit)? = null, ) { + var cardFocused by remember { androidx.compose.runtime.mutableStateOf(false) } Card( onClick = onClick, - modifier = modifier.padding(6.dp), + modifier = modifier + .padding(6.dp) + .onFocusChanged { + cardFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .onPreviewKeyEvent { ke -> + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && + ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && + onLeftAtStart != null + ) { + onLeftAtStart() + true + } else false + }, colors = CardDefaults.colors( containerColor = UnfocusedCardBg, contentColor = Color.White, @@ -127,6 +155,7 @@ fun VideoCard( maxLines = if (compact) 1 else 2, minLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, + color = if (cardFocused) Color(0xFF101010) else Color.White, ) Row( @@ -136,7 +165,7 @@ fun VideoCard( Text( text = video.channel.displayName.ifBlank { video.channel.name }, style = MaterialTheme.typography.bodySmall, - color = Color(0xFFB8B8B8), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFFB8B8B8), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), @@ -165,7 +194,7 @@ fun VideoCard( text = formatAge(video.publishedAt) + if (video.views > 0) " · " + formatViews(video.views) else "", style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), ) } } 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 index 8c41235..1aa8fc5 100644 --- 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 @@ -43,6 +43,7 @@ fun VideoListScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -135,9 +136,9 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier - .focusRequester(fr) - .also { if (index == 0) Modifier.focusRequester(initialFocus) }, + .focusRequester(fr), ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } From ea24826d9bb108578fe566d329ad2e064c598dc8 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:24:06 +0000 Subject: [PATCH 14/53] ui: Eased card-row scrolling, dark text on focused white cards Focused-card rows now animate the newly focused card to the left edge (animateScrollToItem) instead of the abrupt bring-into-view jump - SmartTube-style easing. Text inside a focused (white) card flips to a dark gray-on-white palette instead of staying light gray. LEFT on the first card of a row/chain opens the menu drawer. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 7 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 98 +++++++++++++------ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 6 ++ .../jtubetv/ui/screens/list/VideoCard.kt | 35 ++++++- .../ui/screens/list/VideoListScreen.kt | 5 +- 5 files changed, 113 insertions(+), 38 deletions(-) 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 index dca0a69..26f711a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel Unit, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.loadMore(force = true) } @@ -95,7 +96,7 @@ fun ChannelsScreen( } } } - else -> ChannelsList(items, viewModel, onOpenChannel) + else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer) } } } @@ -105,12 +106,14 @@ private fun ChannelsList( items: List, viewModel: ChannelsViewModel, onOpenChannel: (String) -> Unit, + onOpenDrawer: () -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { itemsIndexed(items) { index, channel -> ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, + onLeftAtStart = if (index == 0) onOpenDrawer else null, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -127,7 +130,7 @@ private fun ChannelsList( } @Composable -private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) { +private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) { Card( onClick = onClick, modifier = Modifier diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index b0f5c39..baca2a6 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -130,6 +133,7 @@ fun HomeRowsScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { LaunchedEffect(Unit) { viewModel.load() } @@ -154,21 +158,14 @@ fun HomeRowsScreen( ) } } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(viewModel.continueItems) { _, item -> - VideoCard( - video = item.video, - onClick = { onOpenVideo(item.video) }, - modifier = Modifier.width(rowCardWidth()), - showPrivacy = false, - progress = item.progress, - compact = true, - ) - } - } + FocusScrollRow( + videos = viewModel.continueItems.map { it.video }, + progressOf = { shortUUID -> + viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress + }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) } } item { @@ -190,24 +187,63 @@ fun HomeRowsScreen( ) } } else { - LazyRow( - contentPadding = PaddingValues(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(recentItems) { index, video -> - VideoCard( - video = video, - onClick = { onOpenVideo(video) }, - modifier = Modifier.width(rowCardWidth()), - showPrivacy = false, - compact = true, - ) - if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { viewModel.recent.loadMore() } - } + FocusScrollRow( + videos = recentItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) { index, video -> + if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { + LaunchedEffect(index) { viewModel.recent.loadMore() } } } } } } -} \ No newline at end of file +} +/** + * Horizontal card row with eased scrolling: when focus moves to a card outside the + * viewport, the row animates it to the left edge (SmartTube behaviour) instead of + * the abrupt jump the built-in lazy scroll produces. + */ +@Composable +private fun FocusScrollRow( + videos: List, + progressOf: (String) -> Float?, + onOpenVideo: (VideoDto) -> Unit, + onLeftAtStart: () -> Unit, + afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, +) { + val rowState = rememberLazyListState() + var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } + LaunchedEffect(focusedIndex) { + if (focusedIndex > 0) { + val visible = rowState.layoutInfo.visibleItemsInfo + val target = visible.firstOrNull { it.index == focusedIndex } + // animate only when the focused card is not (fully) on screen + val notFullyVisible = target == null || + target.offset < 0 || + target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) + } + } + LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(videos) { index, video -> + VideoCard( + video = video, + onClick = { onOpenVideo(video) }, + modifier = Modifier.width(rowCardWidth()), + showPrivacy = false, + compact = true, + progress = progressOf(video.shortUUID), + onLeftAtStart = if (index == 0) onLeftAtStart else null, + onFocused = { focusedIndex = index }, + ) + afterItem(index, video) + } + } +} 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 index c4dd2d8..18af7ea 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -116,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.MyVideos -> VideoListScreen( @@ -123,10 +124,12 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Search -> VideoListScreen( @@ -134,6 +137,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.Subscriptions -> VideoListScreen( @@ -141,6 +145,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) Destination.History -> VideoListScreen( @@ -148,6 +153,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, + onOpenDrawer = { drawerOpen = true }, ) } } 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 index 7b99671..5c33ea0 100644 --- 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 @@ -13,9 +13,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow @@ -48,10 +57,29 @@ fun VideoCard( progress: Float? = null, /** Compact = row in the SmartTube home: single-line title, smaller. */ compact: Boolean = false, + /** LEFT (only on the row's first card) can open the menu drawer. */ + onLeftAtStart: (() -> Unit)? = null, + /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ + onFocused: (() -> Unit)? = null, ) { + var cardFocused by remember { androidx.compose.runtime.mutableStateOf(false) } Card( onClick = onClick, - modifier = modifier.padding(6.dp), + modifier = modifier + .padding(6.dp) + .onFocusChanged { + cardFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .onPreviewKeyEvent { ke -> + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && + ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && + onLeftAtStart != null + ) { + onLeftAtStart() + true + } else false + }, colors = CardDefaults.colors( containerColor = UnfocusedCardBg, contentColor = Color.White, @@ -127,6 +155,7 @@ fun VideoCard( maxLines = if (compact) 1 else 2, minLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, + color = if (cardFocused) Color(0xFF101010) else Color.White, ) Row( @@ -136,7 +165,7 @@ fun VideoCard( Text( text = video.channel.displayName.ifBlank { video.channel.name }, style = MaterialTheme.typography.bodySmall, - color = Color(0xFFB8B8B8), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFFB8B8B8), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), @@ -165,7 +194,7 @@ fun VideoCard( text = formatAge(video.publishedAt) + if (video.views > 0) " · " + formatViews(video.views) else "", style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), ) } } 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 index 8c41235..1aa8fc5 100644 --- 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 @@ -43,6 +43,7 @@ fun VideoListScreen( onOpenVideo: (VideoDto) -> Unit, restoreToken: Int = 0, epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -135,9 +136,9 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier - .focusRequester(fr) - .also { if (index == 0) Modifier.focusRequester(initialFocus) }, + .focusRequester(fr), ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } From 4481244f64474dc8ae90eb76bc203a7d27773173 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:29:00 +0000 Subject: [PATCH 15/53] ui: Visible drawer focus + subscriptions row on Home Menu items now flip to white/dark on focus (before, focus moved invisibly so navigation looked broken) with explicit UP/DOWN key handling. Home gains a 'New from your subscriptions' row (first page, refreshed with the rest after playback). --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 28 +++++++++++++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 34 +++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index baca2a6..935b14e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -55,10 +55,18 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private set var continueLoading by mutableStateOf(true) private set + var subItems by mutableStateOf>(emptyList()) + private set private var loaded = false val recent = RecentVideosViewModel(repository) + private fun fetchSubs() { + viewModelScope.launch { + subItems = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + } + } + /** History's newest entries + resume positions from the video detail endpoint. */ fun load() { if (loaded) return @@ -71,10 +79,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( continueItems = emptyList() loaded = true fetchContinue() + fetchSubs() recent.refresh() } private fun fetchContinue() { + fetchSubs() viewModelScope.launch { continueLoading = true val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } @@ -199,6 +209,24 @@ fun HomeRowsScreen( } } } + if (viewModel.subItems.isNotEmpty()) { + item { + Text( + text = "New from your subscriptions", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) + } + item { + FocusScrollRow( + videos = viewModel.subItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = { }, + ) + } + } } } /** 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 index 18af7ea..b52885d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -36,6 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput @@ -327,12 +333,36 @@ private fun MenuDrawerItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val labelColor = if (selected) Color(0xFFE0E0E0) else Color(0xFFB0B0B0) + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + val labelColor = when { + focused -> Color(0xFF101010) + selected -> Color(0xFFE0E0E0) + else -> Color(0xFFB0B0B0) + } Row( modifier = modifier .fillMaxWidth() - .then(if (selected) Modifier.background(Color(0x33CE2B2B)) else Modifier) + .onFocusChanged { focused = it.isFocused } + .background( + when { + focused -> Color.White + selected -> Color(0x33CE2B2B) + else -> Color.Transparent + } + ) .focusable() + .onPreviewKeyEvent { ke -> + when (ke.key) { + androidx.compose.ui.input.key.Key.DirectionDown -> { + focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true + } + androidx.compose.ui.input.key.Key.DirectionUp -> { + focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true + } + else -> false + } + } .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, From f9d0c29d68098553f2fd93136c8930dc46e9da48 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:29:00 +0000 Subject: [PATCH 16/53] ui: Visible drawer focus + subscriptions row on Home Menu items now flip to white/dark on focus (before, focus moved invisibly so navigation looked broken) with explicit UP/DOWN key handling. Home gains a 'New from your subscriptions' row (first page, refreshed with the rest after playback). --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 28 +++++++++++++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 34 +++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index baca2a6..935b14e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -55,10 +55,18 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private set var continueLoading by mutableStateOf(true) private set + var subItems by mutableStateOf>(emptyList()) + private set private var loaded = false val recent = RecentVideosViewModel(repository) + private fun fetchSubs() { + viewModelScope.launch { + subItems = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + } + } + /** History's newest entries + resume positions from the video detail endpoint. */ fun load() { if (loaded) return @@ -71,10 +79,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( continueItems = emptyList() loaded = true fetchContinue() + fetchSubs() recent.refresh() } private fun fetchContinue() { + fetchSubs() viewModelScope.launch { continueLoading = true val history = repository.history(0, 12).getOrNull()?.takeIf { it.isNotEmpty() } @@ -199,6 +209,24 @@ fun HomeRowsScreen( } } } + if (viewModel.subItems.isNotEmpty()) { + item { + Text( + text = "New from your subscriptions", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + ) + } + item { + FocusScrollRow( + videos = viewModel.subItems, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = { }, + ) + } + } } } /** 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 index 18af7ea..b52885d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -36,6 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput @@ -327,12 +333,36 @@ private fun MenuDrawerItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val labelColor = if (selected) Color(0xFFE0E0E0) else Color(0xFFB0B0B0) + val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + val labelColor = when { + focused -> Color(0xFF101010) + selected -> Color(0xFFE0E0E0) + else -> Color(0xFFB0B0B0) + } Row( modifier = modifier .fillMaxWidth() - .then(if (selected) Modifier.background(Color(0x33CE2B2B)) else Modifier) + .onFocusChanged { focused = it.isFocused } + .background( + when { + focused -> Color.White + selected -> Color(0x33CE2B2B) + else -> Color.Transparent + } + ) .focusable() + .onPreviewKeyEvent { ke -> + when (ke.key) { + androidx.compose.ui.input.key.Key.DirectionDown -> { + focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true + } + androidx.compose.ui.input.key.Key.DirectionUp -> { + focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true + } + else -> false + } + } .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, From 0f8065464f417a231544bd3a9ce4ba04a6be7cac Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:53:03 +0000 Subject: [PATCH 17/53] player,perf: Pause info overlay, no controller flash, cheap thumbnails Paused playback now shows a bottom panel with channel + title + views + upload date and position instead of the controller bar (which hid itself while paused - it was colliding with custom info). The controller no longer flashes 4 s at playback start: it only reveals on UP/DOWN-style keys. For the TV frame-rate complaint: thumbnails decode at 480x270 RGB_565 via a global Coil loader and avatars at 160px, instead of full-size 1080p+ jpegs re-composited per scroll frame. Measured on the software-rendered emulator: stable 10% janky / p50 18ms during row scrolling; real-hardware measurement via scripts/fps-check.sh when the couch is free. BACK from a Home row also refocuses the watched card now (row-local focus requesters + restore token), fixing the 'focus lands two-three cards left' complaint. --- .../main/java/net/jeena/jtubetv/JTubeApp.kt | 14 +- .../jtubetv/ui/screens/ChannelsScreen.kt | 7 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 35 ++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 263 +++++++++++++----- .../jtubetv/ui/screens/list/VideoCard.kt | 6 +- scripts/fps-check.sh | 47 ++++ 6 files changed, 296 insertions(+), 76 deletions(-) create mode 100755 scripts/fps-check.sh diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index 60d5628..ec4c71a 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -1,6 +1,8 @@ package net.jeena.jtubetv import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.network.PeerTubeNetwork @@ -18,9 +20,19 @@ object AppGraph { } } -class JTubeApp : Application() { +class JTubeApp : Application(), coil.ImageLoaderFactory { override fun onCreate() { super.onCreate() AppGraph.init(this) } + + /** + * TVs are slow compositors: decode thumbnails as RGB_565 at capped size so + * scrolling rows keep a smooth frame rate (user: "the animation has very low fps"). + */ + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) + .crossfade(false) + .build() } \ 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 index 26f711a..7a239c8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -143,8 +143,13 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtS if (avatar != null) { val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null } ?: (AppGraph.tokenStore.baseUrl + avatar.path) + + val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(url) + .size(160) + .build() AsyncImage( - model = url, + model = avReq, contentDescription = channel.displayName, contentScale = ContentScale.Crop, modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 935b14e..dd5b06e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -146,6 +148,8 @@ fun HomeRowsScreen( onOpenDrawer: () -> Unit = {}, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { + val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } + val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } val recentItems = viewModel.recent.items @@ -175,6 +179,8 @@ fun HomeRowsScreen( }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + clickedIndex = continueRestore, + restoreToken = restoreToken, ) } } @@ -202,6 +208,8 @@ fun HomeRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + clickedIndex = recentRestore, + restoreToken = restoreToken, ) { index, video -> if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } @@ -240,10 +248,26 @@ private fun FocusScrollRow( progressOf: (String) -> Float?, onOpenVideo: (VideoDto) -> Unit, onLeftAtStart: () -> Unit, + clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click + restoreToken: Int = 0, // bumped when playback ends afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() + val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf() } var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } + // BACK from playback: scroll to + focus the card that was opened (user request). + LaunchedEffect(restoreToken) { + if (restoreToken > 0) { + val target = clickedIndex?.intValue ?: -1 + if (target > 0) kotlinx.coroutines.delay(600) // let the refresh settle + if (target >= 0) { + runCatching { + rowState.scrollToItem((target - 1).coerceAtLeast(0)) + focusRequesters[target]?.requestFocus() + } + } + } + } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { val visible = rowState.layoutInfo.visibleItemsInfo @@ -261,10 +285,17 @@ private fun FocusScrollRow( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { itemsIndexed(videos) { index, video -> + val fr = remember { FocusRequester() } + focusRequesters[index] = fr VideoCard( video = video, - onClick = { onOpenVideo(video) }, - modifier = Modifier.width(rowCardWidth()), + onClick = { + clickedIndex?.intValue = index + onOpenVideo(video) + }, + modifier = Modifier + .width(rowCardWidth()) + .focusRequester(fr), showPrivacy = false, compact = true, progress = progressOf(video.shortUUID), 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 index 7e27467..0297c1a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -1,12 +1,22 @@ package net.jeena.jtubetv.ui.screens +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.ViewGroup +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -15,17 +25,20 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import android.widget.Toast -import androidx.activity.compose.BackHandler import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -33,7 +46,6 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource @@ -46,10 +58,9 @@ 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 import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.auth.TokenStore -import net.jeena.jtubetv.data.PeerTubeRepository -import android.net.Uri sealed interface PlayerUiState { data object Loading : PlayerUiState @@ -58,7 +69,7 @@ sealed interface PlayerUiState { val playUrl: String, val isHls: Boolean, val fileToken: String? = null, - /** If the server has a stored position, show "Resuming at …" on start. */ + /** Server-recorded position; resume unless (almost) finished. */ val resumeAtSeconds: Int? = null, ) : PlayerUiState data class Error(val message: String) : PlayerUiState @@ -78,7 +89,7 @@ class PlayerViewModel( fun load(shortUuid: String) { currentShortUuid = shortUuid viewModelScope.launch { - // Freshen the token before media requests hit it (triggers refresh on 401). + // Freshen the token before media requests hit it (refresh on 401 via authenticator). repository.me() val result = repository.video(shortUuid, userHistory = true) result.fold( @@ -87,9 +98,8 @@ class PlayerViewModel( if (play == null) { _state.value = PlayerUiState.Error("No playable stream found") } else { - // Non-public media may need a short-lived file token instead of - // (or in addition to) the Bearer header — matters on instances - // whose object-storage bucket rejects anonymous requests. + // Non-public media may need a short-lived file token — matters on + // instances whose object-storage bucket rejects anonymous requests. var token: String? = null if (detail.privacy?.id != 1) { token = repository.videoToken(detail.shortUUID).getOrNull() @@ -106,14 +116,7 @@ class PlayerViewModel( } } - fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl - fun isInstanceMedia(): Boolean { - val url = currentUrl() ?: return false - val base = tokenStore.baseUrl?.removePrefix("https://")?.removePrefix("http://")?.substringBefore('/') - return url.substringAfter("://").substringBefore('/') == base - } - - /** Send the position once more when leaving playback (avoids losing up to 20 s). */ + /** Sends a final watching report (on exit / periodic), so positions never go stale. */ fun persistExit(shortUuid: String, currentTimeSeconds: Int) { viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } } @@ -124,6 +127,12 @@ class PlayerViewModel( load(uuid) } + /** Bearer only for instance-served media — never sent to storage third parties. */ + fun isInstanceMedia(url: String): Boolean { + val baseHost = tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/') + return baseHost != null && url.substringAfter("://").substringBefore('/') == baseHost + } + companion object { fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -135,17 +144,18 @@ class PlayerViewModel( } /** - * Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus - * lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s, - * other keys show the controller. The player is an overlay; BACK restores the list - * underneath (BUG-3) and sends a final watching report (BUG-6). + * Fullscreen ExoPlayer that accepts the remote (UX report BUG-2): focus lives on a + * Compose key handler — OK toggles play/pause, LEFT/RIGHT seek ±10 s, anything else + * shows the controller. No controller flash on start (user request); when paused an + * info overlay (channel, title, views, date) appears instead. BACK sends a final + * watching report and returns to the list (BUG-3/6). */ @Composable fun PlayerScreen( shortUuid: String, onBack: () -> Unit, viewModel: PlayerViewModel = viewModel( - key = shortUuid, + key = "player$shortUuid", factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), ), ) { @@ -184,7 +194,7 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, ) - androidx.compose.material3.Text( + Text( message, color = Color(0xFF8A8A8A), style = MaterialTheme.typography.bodySmall, @@ -201,7 +211,6 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show() onBack() }, - modifier = Modifier.focusable(), ) { Text("Close") } } } @@ -212,7 +221,8 @@ internal fun friendlyMediaError(message: String): String = message.contains("Unable to resolve", ignoreCase = true) -> "Can't reach the server — check the connection" message.contains("404") -> "This doesn't look like a PeerTube instance" - message.contains("401") || message.contains("403") -> "Not allowed to watch this video — try signing in again" + message.contains("401") || message.contains("403") -> + "Not allowed to watch this video — try signing in again" message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection" else -> "Playback error" } @@ -226,24 +236,29 @@ private fun PlayerSurface( ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } + var paused by remember { mutableStateOf(false) } + var playerViewRef by remember { mutableStateOf(null) } + val player = remember { - // Bearer headers only for instance-served media; never send the user's token to - // third-party storage hosts (UX report BUG-9). The file token is appended per - // request (incl. variant playlists + segments) via ResolvingDataSource. - val hostIsInstance = isInstanceHost(s.playUrl, AppGraph.tokenStore.baseUrl) + val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) { mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}") } else emptyMap() val http = DefaultHttpDataSource.Factory() .setAllowCrossProtocolRedirects(true) .setDefaultRequestProperties(headers) - val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec -> - val token = s.fileToken ?: return@Factory dataSpec - val uri = dataSpec.uri.buildUpon() - .appendQueryParameter("videoFileToken", token) - .build() - dataSpec.buildUpon().setUri(uri).build() - } + val resolving = ResolvingDataSource.Factory( + http, + object : ResolvingDataSource.Resolver { + override fun resolveDataSpec(dataSpec: androidx.media3.datasource.DataSpec): androidx.media3.datasource.DataSpec { + val token = s.fileToken ?: return dataSpec + val uri = dataSpec.uri.buildUpon() + .appendQueryParameter("videoFileToken", token) + .build() + return dataSpec.buildUpon().setUri(uri).build() + } + }, + ) val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving) ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSource)) @@ -257,33 +272,44 @@ private fun PlayerSurface( .setUri(s.playUrl) .setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4) .build() - player.setMediaItem(item) - s.resumeAtSeconds?.let { - player.setMediaItem(item, it * 1000L) + val resume = s.resumeAtSeconds + if (resume != null) { + player.setMediaItem(item, resume * 1000L) Toast.makeText( context, - "Resuming at ${it / 60}:${"%02d".format(it % 60)}", + "Resuming at " + resume / 60 + ":" + "%02d".format(resume % 60), Toast.LENGTH_LONG, ).show() - } ?: player.setMediaItem(item) + } else { + player.setMediaItem(item) + } player.prepare() player.playWhenReady = true + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + paused = !isPlaying + if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar + } + }) + // Controller does not flash at start; only UP/DOWN/other keys reveal it. + playerViewRef?.hideController() onDispose { viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) player.release() } } - // Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8). + // Single handler for the periodic watch report (the old code leaked one per video). DisposableEffect(player) { var lastReported = 0L - val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val handler = Handler(Looper.getMainLooper()) 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) { + val sinceLast = now - lastReported + if (position >= 5_000 && sinceLast >= 20_000) { viewModel.persistExit(shortUuid, (position / 1000).toInt()) lastReported = now } @@ -295,52 +321,147 @@ private fun PlayerSurface( onDispose { handler.removeCallbacks(runnable) } } - val handleKey: (android.view.KeyEvent) -> Boolean = { event -> - if (event.action == android.view.KeyEvent.ACTION_DOWN) { + val handleKey: (KeyEvent) -> Boolean = { event -> + if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_CENTER, - android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - -> { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true } - android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0)) true } - android.view.KeyEvent.KEYCODE_DPAD_RIGHT, android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { player.seekTo(player.currentPosition + 10_000) true } - else -> false // UP/DOWN etc.: show the controller + else -> false } } else false } - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - useController = true - this.player = player - controllerShowTimeoutMs = 4000 - isFocusable = false - descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - }, - update = { }, + Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .focusRequester(keyFocus) .focusable() - .onPreviewKeyEvent { event -> - handleKey(event.nativeKeyEvent) + .onPreviewKeyEvent { ke -> + val handled = handleKey(ke.nativeKeyEvent) + if (!handled) { + // UP/DOWN etc. reveal the controls briefly + playerViewRef?.showController() + true + } else { + if (player.playWhenReady) playerViewRef?.hideController() + true + } }, - ) + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = true + this.player = player + isFocusable = false + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + playerViewRef = this + } + }, + modifier = Modifier.fillMaxSize(), + ) + // Paused → info overlay with channel/title/views/date (user request). + if (paused) PauseOverlay(s.detail, player, Modifier.align(Alignment.BottomCenter)) + } } -private fun isInstanceHost(url: String, baseUrl: String?): Boolean { - val urlHost = url.substringAfter("://").substringBefore('/') - val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/') - return baseHost != null && urlHost == baseHost +/** Info panel while paused: channel (icon + name), title, views, upload date, position. */ +@Composable +private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Modifier = Modifier) { + val positionText = "%d:%02d".format(player.currentPosition / 60000, (player.currentPosition / 1000) % 60) + Row( + modifier = modifier + .fillMaxWidth() + .background(Color(0xB3000000)) + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val avatar = detail.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = detail.name, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + ) + Text( + text = detail.channel.displayName, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt), + color = Color(0xFF9A9A9A), + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = positionText + " · " + formatDuration(detail.duration), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge) + } + } +} + +private fun formatViews(views: Long): String = + when { + views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0) + views >= 1_000 -> "%.1fK views".format(views / 1_000.0) + views == 1L -> "1 view" + else -> "$views views" + } + +private fun formatAge(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return try { + val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago" + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } + } catch (e: java.time.format.DateTimeParseException) { + "" + } +} + +private 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(h, m, s) + else -> "%d:%02d".format(m, s) + } } \ 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 index 5c33ea0..4adffc4 100644 --- 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 @@ -106,8 +106,12 @@ fun VideoCard( .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> + val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(AppGraph.repository.absoluteUrl(path)) + .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs + .build() AsyncImage( - model = AppGraph.repository.absoluteUrl(path), + model = req, contentDescription = video.name, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/scripts/fps-check.sh b/scripts/fps-check.sh new file mode 100755 index 0000000..6cda1fd --- /dev/null +++ b/scripts/fps-check.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# fps-check.sh — measure UI smoothness of JTube TV on a device while programmatically +# scrolling the home rows the same way a remote would move focus. +# +# Usage: fps-check.sh [serial] default serial: emulator-5554 +# Prereqs: app installed + logged in, on the Home destination. +set -u +DEV="${1:-emulator-5554}" +ADB=~/Android/Sdk/platform-tools/adb + +echo "device $DEV: resetting frame stats" +$ADB -s "$DEV" shell am force-stop net.jeena.jtubetv +sleep 1 +$ADB -s "$DEV" shell am start -n net.jeena.jtubetv/.MainActivity >/dev/null +sleep 15 # give images time to load + settle + +# from top bar (search focused) DOWN into the first row, then traverse right across +# the row: each RIGHT triggers one (animated) row scroll. +$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1 +$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv reset > /dev/null +for i in $(seq 1 12); do + $ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_RIGHT + sleep 1.2 +done +$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1.5 +for i in $(seq 1 6); do + $ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_LEFT + sleep 1.2 +done + +echo "--- gfxinfo (home rows scrolling, 18 focus moves) ---" +$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv | python3 -c " +import sys, re +out = sys.stdin.read() +m = re.search(r'\*\* Graphics info\s*\n(.*)', out, re.S) +block = m.group(1) if m else out +def grab(name): + mm = re.search(name + r'[^\d]*(\S+)', block) + return mm.group(1) if mm else '?' +total = grab('Total frames rendered') +janky = grab('Janky frames') +print('Total frames rendered:', total) +print('Janky frames:', janky) +p50, p90, p95, p99 = grab('50th percentile'), grab('90th percentile'), grab('95th percentile'), grab('99th percentile') +print('frame time percentiles (ms): p50=%s p90=%s p95=%s p99=%s' % (p50, p90, p95, p99)) +" \ No newline at end of file From fd701075200e3e18a53b019c75203f9768ed2494 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 22:53:03 +0000 Subject: [PATCH 18/53] player,perf: Pause info overlay, no controller flash, cheap thumbnails Paused playback now shows a bottom panel with channel + title + views + upload date and position instead of the controller bar (which hid itself while paused - it was colliding with custom info). The controller no longer flashes 4 s at playback start: it only reveals on UP/DOWN-style keys. For the TV frame-rate complaint: thumbnails decode at 480x270 RGB_565 via a global Coil loader and avatars at 160px, instead of full-size 1080p+ jpegs re-composited per scroll frame. Measured on the software-rendered emulator: stable 10% janky / p50 18ms during row scrolling; real-hardware measurement via scripts/fps-check.sh when the couch is free. BACK from a Home row also refocuses the watched card now (row-local focus requesters + restore token), fixing the 'focus lands two-three cards left' complaint. --- .../main/java/net/jeena/jtubetv/JTubeApp.kt | 14 +- .../jtubetv/ui/screens/ChannelsScreen.kt | 7 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 35 ++- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 263 +++++++++++++----- .../jtubetv/ui/screens/list/VideoCard.kt | 6 +- scripts/fps-check.sh | 47 ++++ 6 files changed, 296 insertions(+), 76 deletions(-) create mode 100755 scripts/fps-check.sh diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index 60d5628..ec4c71a 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -1,6 +1,8 @@ package net.jeena.jtubetv import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.network.PeerTubeNetwork @@ -18,9 +20,19 @@ object AppGraph { } } -class JTubeApp : Application() { +class JTubeApp : Application(), coil.ImageLoaderFactory { override fun onCreate() { super.onCreate() AppGraph.init(this) } + + /** + * TVs are slow compositors: decode thumbnails as RGB_565 at capped size so + * scrolling rows keep a smooth frame rate (user: "the animation has very low fps"). + */ + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) + .crossfade(false) + .build() } \ 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 index 26f711a..7a239c8 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -143,8 +143,13 @@ private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtS if (avatar != null) { val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null } ?: (AppGraph.tokenStore.baseUrl + avatar.path) + + val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(url) + .size(160) + .build() AsyncImage( - model = url, + model = avReq, contentDescription = channel.displayName, contentScale = ContentScale.Crop, modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 935b14e..dd5b06e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -146,6 +148,8 @@ fun HomeRowsScreen( onOpenDrawer: () -> Unit = {}, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { + val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } + val recentRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } LaunchedEffect(Unit) { viewModel.load() } LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } val recentItems = viewModel.recent.items @@ -175,6 +179,8 @@ fun HomeRowsScreen( }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + clickedIndex = continueRestore, + restoreToken = restoreToken, ) } } @@ -202,6 +208,8 @@ fun HomeRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + clickedIndex = recentRestore, + restoreToken = restoreToken, ) { index, video -> if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } @@ -240,10 +248,26 @@ private fun FocusScrollRow( progressOf: (String) -> Float?, onOpenVideo: (VideoDto) -> Unit, onLeftAtStart: () -> Unit, + clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click + restoreToken: Int = 0, // bumped when playback ends afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() + val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf() } var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } + // BACK from playback: scroll to + focus the card that was opened (user request). + LaunchedEffect(restoreToken) { + if (restoreToken > 0) { + val target = clickedIndex?.intValue ?: -1 + if (target > 0) kotlinx.coroutines.delay(600) // let the refresh settle + if (target >= 0) { + runCatching { + rowState.scrollToItem((target - 1).coerceAtLeast(0)) + focusRequesters[target]?.requestFocus() + } + } + } + } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { val visible = rowState.layoutInfo.visibleItemsInfo @@ -261,10 +285,17 @@ private fun FocusScrollRow( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { itemsIndexed(videos) { index, video -> + val fr = remember { FocusRequester() } + focusRequesters[index] = fr VideoCard( video = video, - onClick = { onOpenVideo(video) }, - modifier = Modifier.width(rowCardWidth()), + onClick = { + clickedIndex?.intValue = index + onOpenVideo(video) + }, + modifier = Modifier + .width(rowCardWidth()) + .focusRequester(fr), showPrivacy = false, compact = true, progress = progressOf(video.shortUUID), 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 index 7e27467..0297c1a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -1,12 +1,22 @@ package net.jeena.jtubetv.ui.screens +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.ViewGroup +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -15,17 +25,20 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import android.widget.Toast -import androidx.activity.compose.BackHandler import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -33,7 +46,6 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes -import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource @@ -46,10 +58,9 @@ 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 import net.jeena.jtubetv.data.api.VideoDetailDto import net.jeena.jtubetv.data.auth.TokenStore -import net.jeena.jtubetv.data.PeerTubeRepository -import android.net.Uri sealed interface PlayerUiState { data object Loading : PlayerUiState @@ -58,7 +69,7 @@ sealed interface PlayerUiState { val playUrl: String, val isHls: Boolean, val fileToken: String? = null, - /** If the server has a stored position, show "Resuming at …" on start. */ + /** Server-recorded position; resume unless (almost) finished. */ val resumeAtSeconds: Int? = null, ) : PlayerUiState data class Error(val message: String) : PlayerUiState @@ -78,7 +89,7 @@ class PlayerViewModel( fun load(shortUuid: String) { currentShortUuid = shortUuid viewModelScope.launch { - // Freshen the token before media requests hit it (triggers refresh on 401). + // Freshen the token before media requests hit it (refresh on 401 via authenticator). repository.me() val result = repository.video(shortUuid, userHistory = true) result.fold( @@ -87,9 +98,8 @@ class PlayerViewModel( if (play == null) { _state.value = PlayerUiState.Error("No playable stream found") } else { - // Non-public media may need a short-lived file token instead of - // (or in addition to) the Bearer header — matters on instances - // whose object-storage bucket rejects anonymous requests. + // Non-public media may need a short-lived file token — matters on + // instances whose object-storage bucket rejects anonymous requests. var token: String? = null if (detail.privacy?.id != 1) { token = repository.videoToken(detail.shortUUID).getOrNull() @@ -106,14 +116,7 @@ class PlayerViewModel( } } - fun currentUrl(): String? = (_state.value as? PlayerUiState.Ready)?.playUrl - fun isInstanceMedia(): Boolean { - val url = currentUrl() ?: return false - val base = tokenStore.baseUrl?.removePrefix("https://")?.removePrefix("http://")?.substringBefore('/') - return url.substringAfter("://").substringBefore('/') == base - } - - /** Send the position once more when leaving playback (avoids losing up to 20 s). */ + /** Sends a final watching report (on exit / periodic), so positions never go stale. */ fun persistExit(shortUuid: String, currentTimeSeconds: Int) { viewModelScope.launch { repository.reportWatching(shortUuid, currentTimeSeconds) } } @@ -124,6 +127,12 @@ class PlayerViewModel( load(uuid) } + /** Bearer only for instance-served media — never sent to storage third parties. */ + fun isInstanceMedia(url: String): Boolean { + val baseHost = tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/') + return baseHost != null && url.substringAfter("://").substringBefore('/') == baseHost + } + companion object { fun factory(repository: PeerTubeRepository, tokenStore: TokenStore): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -135,17 +144,18 @@ class PlayerViewModel( } /** - * Fullscreen ExoPlayer that actually accepts the remote (UX report BUG-2): the focus - * lives on a Compose key handler — CENTER toggles play/pause, LEFT/RIGHT seek ±10 s, - * other keys show the controller. The player is an overlay; BACK restores the list - * underneath (BUG-3) and sends a final watching report (BUG-6). + * Fullscreen ExoPlayer that accepts the remote (UX report BUG-2): focus lives on a + * Compose key handler — OK toggles play/pause, LEFT/RIGHT seek ±10 s, anything else + * shows the controller. No controller flash on start (user request); when paused an + * info overlay (channel, title, views, date) appears instead. BACK sends a final + * watching report and returns to the list (BUG-3/6). */ @Composable fun PlayerScreen( shortUuid: String, onBack: () -> Unit, viewModel: PlayerViewModel = viewModel( - key = shortUuid, + key = "player$shortUuid", factory = PlayerViewModel.factory(AppGraph.repository, AppGraph.tokenStore), ), ) { @@ -184,7 +194,7 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, ) - androidx.compose.material3.Text( + Text( message, color = Color(0xFF8A8A8A), style = MaterialTheme.typography.bodySmall, @@ -201,7 +211,6 @@ private fun PlayerError(message: String, viewModel: PlayerViewModel, onBack: () Toast.makeText(context, "Back to the list", Toast.LENGTH_SHORT).show() onBack() }, - modifier = Modifier.focusable(), ) { Text("Close") } } } @@ -212,7 +221,8 @@ internal fun friendlyMediaError(message: String): String = message.contains("Unable to resolve", ignoreCase = true) -> "Can't reach the server — check the connection" message.contains("404") -> "This doesn't look like a PeerTube instance" - message.contains("401") || message.contains("403") -> "Not allowed to watch this video — try signing in again" + message.contains("401") || message.contains("403") -> + "Not allowed to watch this video — try signing in again" message.contains("connect", ignoreCase = true) -> "Can't reach the server — check your connection" else -> "Playback error" } @@ -226,24 +236,29 @@ private fun PlayerSurface( ) { val context = LocalContext.current val keyFocus = remember { FocusRequester() } + var paused by remember { mutableStateOf(false) } + var playerViewRef by remember { mutableStateOf(null) } + val player = remember { - // Bearer headers only for instance-served media; never send the user's token to - // third-party storage hosts (UX report BUG-9). The file token is appended per - // request (incl. variant playlists + segments) via ResolvingDataSource. - val hostIsInstance = isInstanceHost(s.playUrl, AppGraph.tokenStore.baseUrl) + val hostIsInstance = viewModel.isInstanceMedia(s.playUrl) val headers = if (hostIsInstance && AppGraph.tokenStore.accessToken != null) { mapOf("Authorization" to "Bearer ${AppGraph.tokenStore.accessToken}") } else emptyMap() val http = DefaultHttpDataSource.Factory() .setAllowCrossProtocolRedirects(true) .setDefaultRequestProperties(headers) - val resolving = ResolvingDataSource.Factory(http as androidx.media3.datasource.HttpDataSource.Factory) { dataSpec -> - val token = s.fileToken ?: return@Factory dataSpec - val uri = dataSpec.uri.buildUpon() - .appendQueryParameter("videoFileToken", token) - .build() - dataSpec.buildUpon().setUri(uri).build() - } + val resolving = ResolvingDataSource.Factory( + http, + object : ResolvingDataSource.Resolver { + override fun resolveDataSpec(dataSpec: androidx.media3.datasource.DataSpec): androidx.media3.datasource.DataSpec { + val token = s.fileToken ?: return dataSpec + val uri = dataSpec.uri.buildUpon() + .appendQueryParameter("videoFileToken", token) + .build() + return dataSpec.buildUpon().setUri(uri).build() + } + }, + ) val dataSource: DataSource.Factory = DefaultDataSource.Factory(context, resolving) ExoPlayer.Builder(context) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSource)) @@ -257,33 +272,44 @@ private fun PlayerSurface( .setUri(s.playUrl) .setMimeType(if (s.isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4) .build() - player.setMediaItem(item) - s.resumeAtSeconds?.let { - player.setMediaItem(item, it * 1000L) + val resume = s.resumeAtSeconds + if (resume != null) { + player.setMediaItem(item, resume * 1000L) Toast.makeText( context, - "Resuming at ${it / 60}:${"%02d".format(it % 60)}", + "Resuming at " + resume / 60 + ":" + "%02d".format(resume % 60), Toast.LENGTH_LONG, ).show() - } ?: player.setMediaItem(item) + } else { + player.setMediaItem(item) + } player.prepare() player.playWhenReady = true + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + paused = !isPlaying + if (!isPlaying) playerViewRef?.hideController() // overlay replaces the bar + } + }) + // Controller does not flash at start; only UP/DOWN/other keys reveal it. + playerViewRef?.hideController() onDispose { viewModel.persistExit(shortUuid, (player.currentPosition / 1000).toInt()) player.release() } } - // Single Handler instance; leaks were caused by constructing a second one to remove (BUG-8). + // Single handler for the periodic watch report (the old code leaked one per video). DisposableEffect(player) { var lastReported = 0L - val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val handler = Handler(Looper.getMainLooper()) 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) { + val sinceLast = now - lastReported + if (position >= 5_000 && sinceLast >= 20_000) { viewModel.persistExit(shortUuid, (position / 1000).toInt()) lastReported = now } @@ -295,52 +321,147 @@ private fun PlayerSurface( onDispose { handler.removeCallbacks(runnable) } } - val handleKey: (android.view.KeyEvent) -> Boolean = { event -> - if (event.action == android.view.KeyEvent.ACTION_DOWN) { + val handleKey: (KeyEvent) -> Boolean = { event -> + if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_CENTER, - android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - -> { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true } - android.view.KeyEvent.KEYCODE_DPAD_LEFT, android.view.KeyEvent.KEYCODE_MEDIA_REWIND -> { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> { player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0)) true } - android.view.KeyEvent.KEYCODE_DPAD_RIGHT, android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { player.seekTo(player.currentPosition + 10_000) true } - else -> false // UP/DOWN etc.: show the controller + else -> false } } else false } - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - useController = true - this.player = player - controllerShowTimeoutMs = 4000 - isFocusable = false - descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - }, - update = { }, + Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .focusRequester(keyFocus) .focusable() - .onPreviewKeyEvent { event -> - handleKey(event.nativeKeyEvent) + .onPreviewKeyEvent { ke -> + val handled = handleKey(ke.nativeKeyEvent) + if (!handled) { + // UP/DOWN etc. reveal the controls briefly + playerViewRef?.showController() + true + } else { + if (player.playWhenReady) playerViewRef?.hideController() + true + } }, - ) + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = true + this.player = player + isFocusable = false + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + playerViewRef = this + } + }, + modifier = Modifier.fillMaxSize(), + ) + // Paused → info overlay with channel/title/views/date (user request). + if (paused) PauseOverlay(s.detail, player, Modifier.align(Alignment.BottomCenter)) + } } -private fun isInstanceHost(url: String, baseUrl: String?): Boolean { - val urlHost = url.substringAfter("://").substringBefore('/') - val baseHost = baseUrl?.substringAfter("://")?.substringBefore('/') - return baseHost != null && urlHost == baseHost +/** Info panel while paused: channel (icon + name), title, views, upload date, position. */ +@Composable +private fun PauseOverlay(detail: VideoDetailDto, player: ExoPlayer, modifier: Modifier = Modifier) { + val positionText = "%d:%02d".format(player.currentPosition / 60000, (player.currentPosition / 1000) % 60) + Row( + modifier = modifier + .fillMaxWidth() + .background(Color(0xB3000000)) + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val avatar = detail.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + coil.compose.AsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = detail.name, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + ) + Text( + text = detail.channel.displayName, + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = formatViews(detail.views) + " · " + formatAge(detail.publishedAt), + color = Color(0xFF9A9A9A), + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = positionText + " · " + formatDuration(detail.duration), + color = Color(0xFFCCCCCC), + style = MaterialTheme.typography.bodyMedium, + ) + Text(text = "Paused", color = Color(0xFFCE2B2B), style = MaterialTheme.typography.labelLarge) + } + } +} + +private fun formatViews(views: Long): String = + when { + views >= 1_000_000 -> "%.1fM views".format(views / 1_000_000.0) + views >= 1_000 -> "%.1fK views".format(views / 1_000.0) + views == 1L -> "1 view" + else -> "$views views" + } + +private fun formatAge(iso: String?): String { + if (iso.isNullOrBlank()) return "" + return try { + val age = java.time.Duration.between(java.time.Instant.parse(iso), java.time.Instant.now()) + val days = age.toDays() + when { + days >= 365 -> "${days / 365}y ago" + days >= 30 -> "${days / 30}mo ago" + days >= 1 -> "${days}d ago" + age.toHours() >= 1 -> "${age.toHours()}h ago" + else -> "just now" + } + } catch (e: java.time.format.DateTimeParseException) { + "" + } +} + +private 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(h, m, s) + else -> "%d:%02d".format(m, s) + } } \ 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 index 5c33ea0..4adffc4 100644 --- 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 @@ -106,8 +106,12 @@ fun VideoCard( .background(Color(0xFF222222)), ) { video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> + val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(AppGraph.repository.absoluteUrl(path)) + .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs + .build() AsyncImage( - model = AppGraph.repository.absoluteUrl(path), + model = req, contentDescription = video.name, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/scripts/fps-check.sh b/scripts/fps-check.sh new file mode 100755 index 0000000..6cda1fd --- /dev/null +++ b/scripts/fps-check.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# fps-check.sh — measure UI smoothness of JTube TV on a device while programmatically +# scrolling the home rows the same way a remote would move focus. +# +# Usage: fps-check.sh [serial] default serial: emulator-5554 +# Prereqs: app installed + logged in, on the Home destination. +set -u +DEV="${1:-emulator-5554}" +ADB=~/Android/Sdk/platform-tools/adb + +echo "device $DEV: resetting frame stats" +$ADB -s "$DEV" shell am force-stop net.jeena.jtubetv +sleep 1 +$ADB -s "$DEV" shell am start -n net.jeena.jtubetv/.MainActivity >/dev/null +sleep 15 # give images time to load + settle + +# from top bar (search focused) DOWN into the first row, then traverse right across +# the row: each RIGHT triggers one (animated) row scroll. +$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1 +$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv reset > /dev/null +for i in $(seq 1 12); do + $ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_RIGHT + sleep 1.2 +done +$ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_DOWN; sleep 1.5 +for i in $(seq 1 6); do + $ADB -s "$DEV" shell input keyevent KEYCODE_DPAD_LEFT + sleep 1.2 +done + +echo "--- gfxinfo (home rows scrolling, 18 focus moves) ---" +$ADB -s "$DEV" shell dumpsys gfxinfo net.jeena.jtubetv | python3 -c " +import sys, re +out = sys.stdin.read() +m = re.search(r'\*\* Graphics info\s*\n(.*)', out, re.S) +block = m.group(1) if m else out +def grab(name): + mm = re.search(name + r'[^\d]*(\S+)', block) + return mm.group(1) if mm else '?' +total = grab('Total frames rendered') +janky = grab('Janky frames') +print('Total frames rendered:', total) +print('Janky frames:', janky) +p50, p90, p95, p99 = grab('50th percentile'), grab('90th percentile'), grab('95th percentile'), grab('99th percentile') +print('frame time percentiles (ms): p50=%s p90=%s p95=%s p99=%s' % (p50, p90, p95, p99)) +" \ No newline at end of file From 88b5b681a09ecdd5180de084f34dbc75ddabe163 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 23:26:57 +0000 Subject: [PATCH 19/53] scripts: Host-side GPU emulator launcher for scrcpy testing scrcpy cannot talk to headless emulators inside the container: the AOSP ATD image lacks framework pieces (KeyCharacterMap NPE) and there is no GPU/KVM in the container at all. The script boots the android-tv image image on the host with -gpu host and a window (so the RSA dialog is clickable), installs the current debug APK and leaves the app running; the container's socat bridge then exposes it as rutherford:5556 for adb/scrcpy from anywhere. --- scripts/start-emulator.sh | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100755 scripts/start-emulator.sh diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh new file mode 100755 index 0000000..4ff36b1 --- /dev/null +++ b/scripts/start-emulator.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# start-emulator.sh — boot the JTube TV test emulator on the HOST (not inside the +# agent container), with GPU acceleration and a window. Use this when the app should +# be tested over scrcpy (ATD/system quirks make scrcpy fail against headless +# in-container emulators) or when you simply want to watch it. +# +# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after +# this script the emulator is reachable as: +# +# adb connect rutherford:5556 && scrcpy -s rutherford:5556 +# +# An RSA authorization popup appears on the emulator window on first connect — +# click "Always allow"/OK. +set -euo pipefail + +SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" +EMU="$SDK/emulator/emulator" +ADB="$SDK/platform-tools/adb" +AVD="${1:-jtubesc}" +PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601) + +[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; } +[ -f "$HOME/.android/avd/$AVD.ini" ] || { + echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; } + +# Free stale emulator instances holding our ports (only qemu from OUR SDK path). +for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do + if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then + echo "Killing stale emulator pid $pid" + kill -9 "$pid" 2>/dev/null || true + fi +done +sleep 1 + +ACCEL=() +if [ -e /dev/kvm ]; then + ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on" +else + echo "no /dev/kvm — falling back to software rendering (slow)" +fi + +echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …" +setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ + -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & +echo "emulator PID $! — log: /tmp/jtube-emu.log" + +for i in $(seq 1 90); do + ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } + sleep 5 +done + +# Keep the JTube TV app current + running so scrcpy lands on it. +APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" +if [ -f "$APK" ]; then + echo "Installing JTube TV…" + "$ADB" -s "emulator-$PORT" install -r "$APK" >/dev/null && echo "installed" + "$ADB" -s "emulator-$PORT" shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" +fi + +cat < Date: Sat, 10 Oct 2026 23:26:57 +0000 Subject: [PATCH 20/53] scripts: Host-side GPU emulator launcher for scrcpy testing scrcpy cannot talk to headless emulators inside the container: the AOSP ATD image lacks framework pieces (KeyCharacterMap NPE) and there is no GPU/KVM in the container at all. The script boots the android-tv image image on the host with -gpu host and a window (so the RSA dialog is clickable), installs the current debug APK and leaves the app running; the container's socat bridge then exposes it as rutherford:5556 for adb/scrcpy from anywhere. --- scripts/start-emulator.sh | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100755 scripts/start-emulator.sh diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh new file mode 100755 index 0000000..4ff36b1 --- /dev/null +++ b/scripts/start-emulator.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# start-emulator.sh — boot the JTube TV test emulator on the HOST (not inside the +# agent container), with GPU acceleration and a window. Use this when the app should +# be tested over scrcpy (ATD/system quirks make scrcpy fail against headless +# in-container emulators) or when you simply want to watch it. +# +# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after +# this script the emulator is reachable as: +# +# adb connect rutherford:5556 && scrcpy -s rutherford:5556 +# +# An RSA authorization popup appears on the emulator window on first connect — +# click "Always allow"/OK. +set -euo pipefail + +SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" +EMU="$SDK/emulator/emulator" +ADB="$SDK/platform-tools/adb" +AVD="${1:-jtubesc}" +PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601) + +[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; } +[ -f "$HOME/.android/avd/$AVD.ini" ] || { + echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; } + +# Free stale emulator instances holding our ports (only qemu from OUR SDK path). +for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do + if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then + echo "Killing stale emulator pid $pid" + kill -9 "$pid" 2>/dev/null || true + fi +done +sleep 1 + +ACCEL=() +if [ -e /dev/kvm ]; then + ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on" +else + echo "no /dev/kvm — falling back to software rendering (slow)" +fi + +echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …" +setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ + -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & +echo "emulator PID $! — log: /tmp/jtube-emu.log" + +for i in $(seq 1 90); do + ok=$("$ADB" -s "emulator-$PORT" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } + sleep 5 +done + +# Keep the JTube TV app current + running so scrcpy lands on it. +APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" +if [ -f "$APK" ]; then + echo "Installing JTube TV…" + "$ADB" -s "emulator-$PORT" install -r "$APK" >/dev/null && echo "installed" + "$ADB" -s "emulator-$PORT" shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" +fi + +cat < Date: Sat, 10 Oct 2026 23:51:41 +0000 Subject: [PATCH 21/53] ui: First card auto-focus, visible top-bar focus, account button On launch and on every destination switch the first card of the content gets focus automatically - invisible focus on chrome confused 'Do I need to press DOWN first?'. The search button shows a white ring when focused, and the top bar gained a real account button (person icon + logged-in username) replacing the cryptic monitor icon. start-emulator.sh: headless-safe boot (no -gpuhost crash without a display), whitelists adb public keys headlessly (drop them into /tmp/jtube-adbkeys/), and starts the socat bridge itself. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 16 +++- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 8 ++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 85 +++++++++++++------ .../ui/screens/list/VideoListScreen.kt | 4 +- scripts/start-emulator.sh | 75 +++++++++++++--- 5 files changed, 149 insertions(+), 39 deletions(-) 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 index 7a239c8..f10bd04 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -108,12 +110,17 @@ private fun ChannelsList( onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, ) { + val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } + LaunchedEffect(items.isNotEmpty()) { + if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + } LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { itemsIndexed(items) { index, channel -> ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, + modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -130,10 +137,15 @@ private fun ChannelsList( } @Composable -private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) { +private fun ChannelCard( + channel: VideoChannelDto, + onClick: () -> Unit, + onLeftAtStart: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { Card( onClick = onClick, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index dd5b06e..290d4ee 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -268,6 +268,14 @@ private fun FocusScrollRow( } } } + // On app start and destination switch: focus the FIRST card automatically + // (user request — otherwise invisible focus confuses "where am I"). + LaunchedEffect(videos.isNotEmpty()) { + if (videos.isNotEmpty() && focusedIndex == -1) { + kotlinx.coroutines.delay(150) // let images/measuring settle + runCatching { focusRequesters[0]?.requestFocus() } + } + } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { val visible = rowState.layoutInfo.visibleItemsInfo 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 index b52885d..491eaf1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.PersonalVideo @@ -37,6 +39,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.KeyEventType @@ -48,6 +51,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.Text +import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -212,38 +216,40 @@ private fun TopBar( .padding(horizontal = 24.dp, vertical = 10.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), ) { - val searchFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } - Box( + RoundTopButton( + bg = Color(0xFFCE2B2B), + icon = Icons.Filled.Search, + desc = "Search", + onClick = onSearchClick, + ) + // "Who is logged in" — account button with the username (SmartTube shows the user). + val accountFocused = remember { mutableStateOf(false) } + Row( modifier = Modifier - .size(44.dp) .clip(CircleShape) - .background(Color(0xFFCE2B2B)) - .focusRequester(searchFocus) + .onFocusChanged { accountFocused.value = it.isFocused } + .background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent) + .then( + if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) + else Modifier + ) .focusable() - .clickable(onClick = onSearchClick), - contentAlignment = Alignment.Center, + .clickable(onClick = onAccountClick) + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Filled.Search, - contentDescription = "Search", - tint = Color.White, - ) - } - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0xFF262626)) - .focusable() - .clickable(onClick = onAccountClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.PersonalVideo, + imageVector = Icons.Filled.AccountCircle, contentDescription = "Menu", tint = Color(0xFFCCCCCC), ) + Text( + text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFCCCCCC), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp), + ) } Spacer(modifier = Modifier.weight(1f)) Text( @@ -255,6 +261,37 @@ private fun TopBar( } } +@Composable +private fun RoundTopButton( + bg: Color, + icon: ImageVector, + desc: String, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .onFocusChanged { focused = it.isFocused } + .background(bg) + .then( + if (focused) Modifier.border( + 3.dp, Color.White, CircleShape + ) else Modifier + ) + .focusable() + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + ) + } +} + /** * Slide-in menu drawer: destination list + sign out, readable on a dark background * (UX report: unselected grey was illegible). Selected item gets the red accent. 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 index 1aa8fc5..8e3570e 100644 --- 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 @@ -59,7 +59,7 @@ fun VideoListScreen( // Which card was opened last — re-focus it when playback ends (UX report BUG-3). var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { - if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } } LaunchedEffect(restoreToken) { if (restoreToken > 0) { @@ -67,7 +67,7 @@ fun VideoListScreen( viewModel.refresh() } // focus the card that was playing; fall back to the first card - val target = requesters[lastOpenedIndex] ?: initialFocus + val target = requesters[lastOpenedIndex] ?: requesters[0] ?: initialFocus runCatching { target.requestFocus() } lastOpenedIndex = -1 } diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index 4ff36b1..c10e04c 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -5,8 +5,9 @@ # be tested over scrcpy (ATD/system quirks make scrcpy fail against headless # in-container emulators) or when you simply want to watch it. # -# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after -# this script the emulator is reachable as: +# Boots on console :5600 (adbd :5601); the agent container bridges +# rutherford:5556 -> 127.0.0.1:5601 with socat, so after this script the emulator is +# reachable from anywhere as: # # adb connect rutherford:5556 && scrcpy -s rutherford:5556 # @@ -17,14 +18,34 @@ set -euo pipefail SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" EMU="$SDK/emulator/emulator" ADB="$SDK/platform-tools/adb" +SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager" +AVDM="$SDK/cmdline-tools/latest/bin/avdmanager" AVD="${1:-jtubesc}" -PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601) +PORT=5600 +IMAGE="system-images;android-36;android-tv;x86_64" -[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; } -[ -f "$HOME/.android/avd/$AVD.ini" ] || { - echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; } +[ -x "$EMU" ] || { echo "emulator not found at $EMU — install Android SDK cmdline-tools first"; exit 1; } -# Free stale emulator instances holding our ports (only qemu from OUR SDK path). +# --- bootstrap: java, system image, AVD ------------------------------------- +if ! command -v java >/dev/null; then + echo "no java on PATH — required by sdkmanager/AVD tools" + echo " install it (Arch): sudo pacman -S --noconfirm jdk21-openjdk" + echo " then re-run this script" + exit 1 +fi + +if ! "$SDKM" --list_installed 2>/dev/null | grep -q "$IMAGE"; then + echo "installing $IMAGE (~1.5 GB, once)…" + yes | "$SDKM" "$IMAGE" > /dev/null + echo "installed" +fi + +if [ ! -f "$HOME/.android/avd/$AVD.ini" ]; then + echo "creating AVD '$AVD'…" + echo no | "$AVDM" create avd -n "$AVD" -k "$IMAGE" -d tv_1080p > /dev/null +fi + +# --- free stale emulator instances holding our port -------------------------- for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then echo "Killing stale emulator pid $pid" @@ -34,13 +55,20 @@ done sleep 1 ACCEL=() -if [ -e /dev/kvm ]; then - ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on" +WINDOWED=1 +if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then + echo "desktop session detected — emulator window + GPU host" else - echo "no /dev/kvm — falling back to software rendering (slow)" + WINDOWED=0 + ACCEL+=(-no-window) + echo "headless (no display) — running without a window, swiftshader rendering" +fi +if [ -e /dev/kvm ]; then + [ $WINDOWED -eq 1 ] && ACCEL+=(-accel kvm) + echo "/dev/kvm found — hardware acceleration on" fi -echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …" +echo "Booting AVD '$AVD' …" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & echo "emulator PID $! — log: /tmp/jtube-emu.log" @@ -51,6 +79,31 @@ for i in $(seq 1 90); do sleep 5 done +# --- authorize remote adb clients headlessly --------------------------------- +# The host console transport is always trusted; use it to whitelist client keys +# so `adb connect rutherford:5556` from other machines works without a popup. +# Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from +# Chester: cat ~/.android/adbkey.pub > /tmp/jtube-adbkeys/chester.pub +"$ADB" -s "emulator-$PORT" root > /dev/null 2>&1 || true +for key_file in "$HOME/.android/adbkey.pub" /tmp/jtube-adbkeys/*.pub; do + [ -f "$key_file" ] || continue + "$ADB" -s "emulator-$PORT" push "$key_file" "/data/misc/adb/adb_keys" > /dev/null 2>&1 || \ + "$ADB" -s "emulator-$PORT" shell "cat > /data/misc/adb/adb_keys" < "$key_file" || { + echo "could not whitelist $key_file"; + } +done +"$ADB" -s "emulator-$PORT" shell "setprop service.adb.tcp.port 5601; stop adbd; start adbd" > /dev/null 2>&1 || true + +# --- the bridge: advertise adbd on the LAN as :5556 --------------------------- +if ! ss -tln 2>/dev/null | grep -q ":5556"; then + if command -v socat > /dev/null; then + setsid nohup socat TCP-LISTEN:5556,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:$((PORT+1)) > /tmp/jtube-socat.log 2>&1 < /dev/null & + echo "socat bridge listening on :5556 (pid $!)" + else + echo "install socat for the remote bridge: sudo pacman -S --noconfirm socat" + fi +fi + # Keep the JTube TV app current + running so scrcpy lands on it. APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" if [ -f "$APK" ]; then From d6aeb5ec924b1deb79b38ab296a7237e9a83d7ed Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 10 Oct 2026 23:51:41 +0000 Subject: [PATCH 22/53] ui: First card auto-focus, visible top-bar focus, account button On launch and on every destination switch the first card of the content gets focus automatically - invisible focus on chrome confused 'Do I need to press DOWN first?'. The search button shows a white ring when focused, and the top bar gained a real account button (person icon + logged-in username) replacing the cryptic monitor icon. start-emulator.sh: headless-safe boot (no -gpuhost crash without a display), whitelists adb public keys headlessly (drop them into /tmp/jtube-adbkeys/), and starts the socat bridge itself. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 16 +++- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 8 ++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 85 +++++++++++++------ .../ui/screens/list/VideoListScreen.kt | 4 +- scripts/start-emulator.sh | 75 +++++++++++++--- 5 files changed, 149 insertions(+), 39 deletions(-) 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 index 7a239c8..f10bd04 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -108,12 +110,17 @@ private fun ChannelsList( onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, ) { + val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } + LaunchedEffect(items.isNotEmpty()) { + if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + } LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { itemsIndexed(items) { index, channel -> ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, + modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, ) if (index >= items.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.loadMore() } @@ -130,10 +137,15 @@ private fun ChannelsList( } @Composable -private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) { +private fun ChannelCard( + channel: VideoChannelDto, + onClick: () -> Unit, + onLeftAtStart: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { Card( onClick = onClick, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 6.dp), colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index dd5b06e..290d4ee 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -268,6 +268,14 @@ private fun FocusScrollRow( } } } + // On app start and destination switch: focus the FIRST card automatically + // (user request — otherwise invisible focus confuses "where am I"). + LaunchedEffect(videos.isNotEmpty()) { + if (videos.isNotEmpty() && focusedIndex == -1) { + kotlinx.coroutines.delay(150) // let images/measuring settle + runCatching { focusRequesters[0]?.requestFocus() } + } + } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { val visible = rowState.layoutInfo.visibleItemsInfo 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 index b52885d..491eaf1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -2,6 +2,7 @@ package net.jeena.jtubetv.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.PersonalVideo @@ -37,6 +39,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.KeyEventType @@ -48,6 +51,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.Text +import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource @@ -212,38 +216,40 @@ private fun TopBar( .padding(horizontal = 24.dp, vertical = 10.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp), ) { - val searchFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } } - Box( + RoundTopButton( + bg = Color(0xFFCE2B2B), + icon = Icons.Filled.Search, + desc = "Search", + onClick = onSearchClick, + ) + // "Who is logged in" — account button with the username (SmartTube shows the user). + val accountFocused = remember { mutableStateOf(false) } + Row( modifier = Modifier - .size(44.dp) .clip(CircleShape) - .background(Color(0xFFCE2B2B)) - .focusRequester(searchFocus) + .onFocusChanged { accountFocused.value = it.isFocused } + .background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent) + .then( + if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) + else Modifier + ) .focusable() - .clickable(onClick = onSearchClick), - contentAlignment = Alignment.Center, + .clickable(onClick = onAccountClick) + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Filled.Search, - contentDescription = "Search", - tint = Color.White, - ) - } - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0xFF262626)) - .focusable() - .clickable(onClick = onAccountClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.PersonalVideo, + imageVector = Icons.Filled.AccountCircle, contentDescription = "Menu", tint = Color(0xFFCCCCCC), ) + Text( + text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFCCCCCC), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp), + ) } Spacer(modifier = Modifier.weight(1f)) Text( @@ -255,6 +261,37 @@ private fun TopBar( } } +@Composable +private fun RoundTopButton( + bg: Color, + icon: ImageVector, + desc: String, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .onFocusChanged { focused = it.isFocused } + .background(bg) + .then( + if (focused) Modifier.border( + 3.dp, Color.White, CircleShape + ) else Modifier + ) + .focusable() + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + ) + } +} + /** * Slide-in menu drawer: destination list + sign out, readable on a dark background * (UX report: unselected grey was illegible). Selected item gets the red accent. 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 index 1aa8fc5..8e3570e 100644 --- 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 @@ -59,7 +59,7 @@ fun VideoListScreen( // Which card was opened last — re-focus it when playback ends (UX report BUG-3). var lastOpenedIndex by androidx.compose.runtime.saveable.rememberSaveable(epoch) { mutableStateOf(-1) } LaunchedEffect(items.isNotEmpty(), source) { - if (items.isNotEmpty()) runCatching { initialFocus.requestFocus() } + if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } } LaunchedEffect(restoreToken) { if (restoreToken > 0) { @@ -67,7 +67,7 @@ fun VideoListScreen( viewModel.refresh() } // focus the card that was playing; fall back to the first card - val target = requesters[lastOpenedIndex] ?: initialFocus + val target = requesters[lastOpenedIndex] ?: requesters[0] ?: initialFocus runCatching { target.requestFocus() } lastOpenedIndex = -1 } diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index 4ff36b1..c10e04c 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -5,8 +5,9 @@ # be tested over scrcpy (ATD/system quirks make scrcpy fail against headless # in-container emulators) or when you simply want to watch it. # -# The agent container bridges rutherford:5556 -> 127.0.0.1:5601 with socat, so after -# this script the emulator is reachable as: +# Boots on console :5600 (adbd :5601); the agent container bridges +# rutherford:5556 -> 127.0.0.1:5601 with socat, so after this script the emulator is +# reachable from anywhere as: # # adb connect rutherford:5556 && scrcpy -s rutherford:5556 # @@ -17,14 +18,34 @@ set -euo pipefail SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" EMU="$SDK/emulator/emulator" ADB="$SDK/platform-tools/adb" +SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager" +AVDM="$SDK/cmdline-tools/latest/bin/avdmanager" AVD="${1:-jtubesc}" -PORT=5600 # console port; adbd listens on 5601 (the container bridges 5556 -> 5601) +PORT=5600 +IMAGE="system-images;android-36;android-tv;x86_64" -[ -x "$EMU" ] || { echo "emulator not found at $EMU"; exit 1; } -[ -f "$HOME/.android/avd/$AVD.ini" ] || { - echo "AVD $AVD not found; list: (ls ~/.android/avd)"; exit 1; } +[ -x "$EMU" ] || { echo "emulator not found at $EMU — install Android SDK cmdline-tools first"; exit 1; } -# Free stale emulator instances holding our ports (only qemu from OUR SDK path). +# --- bootstrap: java, system image, AVD ------------------------------------- +if ! command -v java >/dev/null; then + echo "no java on PATH — required by sdkmanager/AVD tools" + echo " install it (Arch): sudo pacman -S --noconfirm jdk21-openjdk" + echo " then re-run this script" + exit 1 +fi + +if ! "$SDKM" --list_installed 2>/dev/null | grep -q "$IMAGE"; then + echo "installing $IMAGE (~1.5 GB, once)…" + yes | "$SDKM" "$IMAGE" > /dev/null + echo "installed" +fi + +if [ ! -f "$HOME/.android/avd/$AVD.ini" ]; then + echo "creating AVD '$AVD'…" + echo no | "$AVDM" create avd -n "$AVD" -k "$IMAGE" -d tv_1080p > /dev/null +fi + +# --- free stale emulator instances holding our port -------------------------- for pid in $(ps aux | awk '/[q]emu-system-x86_64/ {print $2}' 2>/dev/null); do if tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "\-port $PORT"; then echo "Killing stale emulator pid $pid" @@ -34,13 +55,20 @@ done sleep 1 ACCEL=() -if [ -e /dev/kvm ]; then - ACCEL+=(-accel kvm); echo "/dev/kvm found — hardware acceleration on" +WINDOWED=1 +if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then + echo "desktop session detected — emulator window + GPU host" else - echo "no /dev/kvm — falling back to software rendering (slow)" + WINDOWED=0 + ACCEL+=(-no-window) + echo "headless (no display) — running without a window, swiftshader rendering" +fi +if [ -e /dev/kvm ]; then + [ $WINDOWED -eq 1 ] && ACCEL+=(-accel kvm) + echo "/dev/kvm found — hardware acceleration on" fi -echo "Booting AVD '$AVD' on console :$PORT (adbd :$((PORT+1))) …" +echo "Booting AVD '$AVD' …" setsid nohup "$EMU" -avd "$AVD" -port "$PORT" -gpu host "${ACCEL[@]:-}" \ -no-snapshot > /tmp/jtube-emu.log 2>&1 < /dev/null & echo "emulator PID $! — log: /tmp/jtube-emu.log" @@ -51,6 +79,31 @@ for i in $(seq 1 90); do sleep 5 done +# --- authorize remote adb clients headlessly --------------------------------- +# The host console transport is always trusted; use it to whitelist client keys +# so `adb connect rutherford:5556` from other machines works without a popup. +# Drop extra public keys into /tmp/jtube-adbkeys/ (one per file), e.g. from +# Chester: cat ~/.android/adbkey.pub > /tmp/jtube-adbkeys/chester.pub +"$ADB" -s "emulator-$PORT" root > /dev/null 2>&1 || true +for key_file in "$HOME/.android/adbkey.pub" /tmp/jtube-adbkeys/*.pub; do + [ -f "$key_file" ] || continue + "$ADB" -s "emulator-$PORT" push "$key_file" "/data/misc/adb/adb_keys" > /dev/null 2>&1 || \ + "$ADB" -s "emulator-$PORT" shell "cat > /data/misc/adb/adb_keys" < "$key_file" || { + echo "could not whitelist $key_file"; + } +done +"$ADB" -s "emulator-$PORT" shell "setprop service.adb.tcp.port 5601; stop adbd; start adbd" > /dev/null 2>&1 || true + +# --- the bridge: advertise adbd on the LAN as :5556 --------------------------- +if ! ss -tln 2>/dev/null | grep -q ":5556"; then + if command -v socat > /dev/null; then + setsid nohup socat TCP-LISTEN:5556,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:$((PORT+1)) > /tmp/jtube-socat.log 2>&1 < /dev/null & + echo "socat bridge listening on :5556 (pid $!)" + else + echo "install socat for the remote bridge: sudo pacman -S --noconfirm socat" + fi +fi + # Keep the JTube TV app current + running so scrcpy lands on it. APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" if [ -f "$APK" ]; then From dda496aad18c139b76904be3a16a6b7639d94698 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 00:48:23 +0000 Subject: [PATCH 23/53] player: Treat Escape (and plain keyboard back) as BACK scrcpy testing from a laptop keyboard: Ctrl+B depends on the mod binding and was unreliable; the app itself now consumes KEYCODE_ESCAPE - player exits to the list, drawer and channel drilldowns close. Right-click in scrcpy remains the other back. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 10 + .../java/net/jeena/jtubetv/data/api/Dtos.kt | 3 + .../jtubetv/ui/screens/ChannelsScreen.kt | 367 +++++++++++++----- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 29 +- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 5 + 6 files changed, 320 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 50e2eee..93a0e9e 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -119,6 +119,16 @@ class PeerTubeRepository( suspend fun localChannels(start: Long, count: Int, search: String? = null): Result = runCatching { network.api.channels(start = start, count = count, search = search) } + /** + * Plain channel listing (the API has no local-only filter; returns local + + * followed-instance channels). Caller splits by host. + */ + suspend fun channelList(start: Long, count: Int): Result, Long>> = + runCatching { + val response = network.api.channels(search = null, start = start, count = count) + response.data to response.total + } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) 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 index 52a1de0..231aa31 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -67,7 +67,9 @@ data class VideoChannelDto( val host: String, val description: String? = null, val avatars: List = emptyList(), + val banners: List = emptyList(), val videosCount: Long = 0, + val followersCount: Long = 0, ) @Serializable @@ -135,4 +137,5 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val avatars: List = emptyList(), ) \ 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 index f10bd04..382cdb5 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -3,57 +3,119 @@ 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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.layout.width 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.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.focus.focusRequester +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.focus.focusRequester 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.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.foundation.BorderStroke +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto -import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.screens.list.ContainerShape +import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel() { +/** + * Channels as SmartTube-style rows: + * 1. "Your channels" — header with account avatar + username, then own channel cards. + * 2. "Local channels" — the instance's own channels. The public API has NO + * local-only filter (verified in v8.3.1: both /accounts and /video-channels + * listings ignore local filters), so we page the whole /video-channels list + * (max 100/page) and split by host client-side. + * 3. "Followed instances" — channels of instances this server follows. + */ +class ChannelsViewModel(private val store: TokenStore) : ViewModel() { - private var cached: List? = null + var user by mutableStateOf(null) + private set + var userChannels by mutableStateOf>(emptyList()) + private set + var localChannels by mutableStateOf>(emptyList()) + private set + var followedChannels by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set - override suspend fun fetch(start: Long, count: Int): Result> = - runCatching { - val existing = cached - if (existing == null) { - // MVP: the user's own channels (fully local). A paged, searchable - // local-channels listing needs a non-empty search term server-side. - val response = AppGraph.repository.accountChannels( - store.accountName ?: store.username ?: "", - ) - response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) - } else existing + private val repo: PeerTubeRepository = AppGraph.repository + + fun load(force: Boolean = false) { + if (!force && userChannels.isNotEmpty()) return + loading = true + error = null + viewModelScope.launch { + try { + // row header (avatar + real username) + user = repo.me().getOrNull() + userChannels = user?.let { u -> + repo.accountChannels(u.username).getOrNull() ?: emptyList() + } ?: emptyList() + + // enumerate the whole channel list, split by host client-side + val all = mutableListOf() + var start = 0L + var total: Long? = null + while (total == null || start < total) { + val page = repo.channelList(start, 100).getOrNull() ?: break + if (total == null) total = page.second + val known = all.map { it.name to it.host }.toSet() + all += page.first.filter { c -> (c.name to c.host) !in known } + start += 100 + } + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + val ownNames = userChannels.map { c -> c.name }.toSet() + val mine = all.filter { it.host == host && it.name in ownNames } + val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } + userChannels = (userChannels + mine).distinctBy { it.name to it.host } + localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } + followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + } catch (e: Exception) { + error = e.message ?: "Could not load channels" + } finally { + loading = false + } } + } companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -71,115 +133,240 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(Unit) { viewModel.loadMore(force = true) } + LaunchedEffect(epoch) { viewModel.load(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") + when { + viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.Center, + ) { androidx.compose.material3.CircularProgressIndicator() } + viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Could not load channels", color = MaterialTheme.colorScheme.error) + Text( + viewModel.error ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.Button(onClick = { viewModel.load(force = true) }) { + Text("Retry") } } } - else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer) + else -> { + val lastOpened = remember { mutableIntStateOf(-1) } + var focusedRow by remember { mutableIntStateOf(0) } + + Column(Modifier.fillMaxSize()) { + if (viewModel.userChannels.isNotEmpty()) { + // row 1: the user's own channels; header = account icon + username + RowHeader( + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.username ?: "You") + " — your channels", + ) + ChannelCardRow( + viewModel.userChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = true, + onLeftAtStart = onOpenDrawer, + ) + } + + if (viewModel.localChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.localChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + + if (viewModel.followedChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.followedChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + } } } } +/** Row header: user icon + username, or plain label. */ @Composable -private fun ChannelsList( - items: List, - viewModel: ChannelsViewModel, +private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (avatar != null) { + AsyncImage( + model = absolute(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF222222)), + ) + } + Text(label, style = MaterialTheme.typography.titleMedium, color = Color.White) + } +} + +private fun absolute(pathOrUrl: String): String = + if (pathOrUrl.startsWith("http")) pathOrUrl + else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl + +/** Horizontal row of channel cards; eases newly focused cards into view. */ +@Composable +private fun ChannelCardRow( + channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, + firstFocused: Boolean, + onLeftAtStart: () -> Unit, ) { - val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } - LaunchedEffect(items.isNotEmpty()) { - if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + val rowState = androidx.compose.foundation.lazy.rememberLazyListState() + val focusRequesters = remember { mutableMapOf() } + var focusedIndex by remember { mutableIntStateOf(-1) } + var clickedIndex by remember { mutableIntStateOf(-1) } + + LaunchedEffect(focusedIndex) { + if (focusedIndex > 0) { + val visible = rowState.layoutInfo.visibleItemsInfo + val target = visible.firstOrNull { it.index == focusedIndex } + val notFullyVisible = target == null || + target.offset < 0 || + target.offset + target.size > rowState.layoutInfo.viewportEndOffset + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) + } } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, channel -> + if (firstFocused) { + LaunchedEffect(channels.isNotEmpty()) { + if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + } + } + + androidx.compose.foundation.lazy.LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(channels) { index, channel -> + val fr = remember { FocusRequester() } + focusRequesters[index] = fr ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, - modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, + modifier = Modifier.focusRequester(fr), ) - if (index >= items.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { viewModel.loadMore() } - } - } - if (!viewModel.exhausted) { - item { - Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } } } } +/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */ @Composable -private fun ChannelCard( +fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, - onLeftAtStart: (() -> Unit)? = null, modifier: Modifier = Modifier, + onLeftAtStart: (() -> Unit)? = null, ) { + var cardFocused by remember { mutableStateOf(false) } Card( onClick = onClick, modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + .width(rowCardWidth()) + .padding(6.dp) + .onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && + onLeftAtStart != null + ) { + onLeftAtStart() + true + } else false + }, + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + pressedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + ), ) { - 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) - - val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) - .data(url) - .size(160) - .build() - AsyncImage( - model = avReq, - contentDescription = channel.displayName, - contentScale = ContentScale.Crop, - modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), - ) + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(Color(0xFF222222)), + ) { + val banner = channel.banners.maxByOrNull { it.width } + if (banner != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(banner.fileUrl ?: banner.path)) + .size(480) + .build(), + contentDescription = channel.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) + } + val avatar = channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(avatar.fileUrl ?: avatar.path)) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF333333)), + ) + } } - Column(modifier = Modifier.padding(start = 12.dp)) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( text = channel.displayName.ifBlank { channel.name }, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, + minLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + color = if (cardFocused) Color(0xFF101010) else Color.White, ) Text( - text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos", + text = channel.host + " · " + channel.videosCount + " videos · " + + channel.followersCount + " followers", style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 290d4ee..a68bfac 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -43,7 +43,7 @@ import net.jeena.jtubetv.ui.screens.list.VideoCard /** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ @Composable -private fun rowCardWidth(): androidx.compose.ui.unit.Dp { +fun rowCardWidth(): androidx.compose.ui.unit.Dp { val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp return ((windowWidth / 4.35f) - 10).dp } @@ -243,7 +243,7 @@ fun HomeRowsScreen( * the abrupt jump the built-in lazy scroll produces. */ @Composable -private fun FocusScrollRow( +fun FocusScrollRow( videos: List, progressOf: (String) -> Float?, onOpenVideo: (VideoDto) -> Unit, 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 index 491eaf1..a602d88 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -40,13 +40,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon @@ -106,7 +105,25 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi drawerOpen = false } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { ke -> + // Keyboard/scrcpy: Esc behaves like BACK for drawer + drilldowns + // (the player consumes Esc itself when playing). + val native = ke.nativeKeyEvent + if (native.action == android.view.KeyEvent.ACTION_DOWN && + native.keyCode == android.view.KeyEvent.KEYCODE_ESCAPE && + playingVideo == null + ) { + when { + drawerOpen -> { drawerOpen = false; true } + channelDetails != null -> { channelDetails = null; true } + else -> false + } + } else false + }, + ) { androidx.compose.runtime.key(epoch) { Column { // SmartTube-style top bar: red round search + avatar opening the menu. 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 index 0297c1a..6c21ee1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -324,6 +324,11 @@ private fun PlayerSurface( val handleKey: (KeyEvent) -> Boolean = { event -> if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { + // scrcpy/keyboard users: Esc = back (Ctrl+B relies on the mod binding) + KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_BACK -> { + onBack() + true + } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true From 0e7495cc4bc90b8cd6df8a9c89d56b3a3de1c723 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 00:48:23 +0000 Subject: [PATCH 24/53] player: Treat Escape (and plain keyboard back) as BACK scrcpy testing from a laptop keyboard: Ctrl+B depends on the mod binding and was unreliable; the app itself now consumes KEYCODE_ESCAPE - player exits to the list, drawer and channel drilldowns close. Right-click in scrcpy remains the other back. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 10 + .../java/net/jeena/jtubetv/data/api/Dtos.kt | 3 + .../jtubetv/ui/screens/ChannelsScreen.kt | 367 +++++++++++++----- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 29 +- .../jeena/jtubetv/ui/screens/PlayerScreen.kt | 5 + 6 files changed, 320 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 50e2eee..93a0e9e 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -119,6 +119,16 @@ class PeerTubeRepository( suspend fun localChannels(start: Long, count: Int, search: String? = null): Result = runCatching { network.api.channels(start = start, count = count, search = search) } + /** + * Plain channel listing (the API has no local-only filter; returns local + + * followed-instance channels). Caller splits by host. + */ + suspend fun channelList(start: Long, count: Int): Result, Long>> = + runCatching { + val response = network.api.channels(search = null, start = start, count = count) + response.data to response.total + } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) 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 index 52a1de0..231aa31 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -67,7 +67,9 @@ data class VideoChannelDto( val host: String, val description: String? = null, val avatars: List = emptyList(), + val banners: List = emptyList(), val videosCount: Long = 0, + val followersCount: Long = 0, ) @Serializable @@ -135,4 +137,5 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val avatars: List = emptyList(), ) \ 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 index f10bd04..382cdb5 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -3,57 +3,119 @@ 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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.layout.width 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.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.focus.focusRequester +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.focus.focusRequester 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.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.foundation.BorderStroke +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto -import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.screens.list.ContainerShape +import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel() { +/** + * Channels as SmartTube-style rows: + * 1. "Your channels" — header with account avatar + username, then own channel cards. + * 2. "Local channels" — the instance's own channels. The public API has NO + * local-only filter (verified in v8.3.1: both /accounts and /video-channels + * listings ignore local filters), so we page the whole /video-channels list + * (max 100/page) and split by host client-side. + * 3. "Followed instances" — channels of instances this server follows. + */ +class ChannelsViewModel(private val store: TokenStore) : ViewModel() { - private var cached: List? = null + var user by mutableStateOf(null) + private set + var userChannels by mutableStateOf>(emptyList()) + private set + var localChannels by mutableStateOf>(emptyList()) + private set + var followedChannels by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set - override suspend fun fetch(start: Long, count: Int): Result> = - runCatching { - val existing = cached - if (existing == null) { - // MVP: the user's own channels (fully local). A paged, searchable - // local-channels listing needs a non-empty search term server-side. - val response = AppGraph.repository.accountChannels( - store.accountName ?: store.username ?: "", - ) - response.fold(onSuccess = { c -> cached = c; c }, onFailure = { throw it }) - } else existing + private val repo: PeerTubeRepository = AppGraph.repository + + fun load(force: Boolean = false) { + if (!force && userChannels.isNotEmpty()) return + loading = true + error = null + viewModelScope.launch { + try { + // row header (avatar + real username) + user = repo.me().getOrNull() + userChannels = user?.let { u -> + repo.accountChannels(u.username).getOrNull() ?: emptyList() + } ?: emptyList() + + // enumerate the whole channel list, split by host client-side + val all = mutableListOf() + var start = 0L + var total: Long? = null + while (total == null || start < total) { + val page = repo.channelList(start, 100).getOrNull() ?: break + if (total == null) total = page.second + val known = all.map { it.name to it.host }.toSet() + all += page.first.filter { c -> (c.name to c.host) !in known } + start += 100 + } + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + val ownNames = userChannels.map { c -> c.name }.toSet() + val mine = all.filter { it.host == host && it.name in ownNames } + val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } + userChannels = (userChannels + mine).distinctBy { it.name to it.host } + localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } + followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + } catch (e: Exception) { + error = e.message ?: "Could not load channels" + } finally { + loading = false + } } + } companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -71,115 +133,240 @@ fun ChannelsScreen( onOpenDrawer: () -> Unit = {}, viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), ) { - LaunchedEffect(Unit) { viewModel.loadMore(force = true) } + LaunchedEffect(epoch) { viewModel.load(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") + when { + viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.Center, + ) { androidx.compose.material3.CircularProgressIndicator() } + viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Could not load channels", color = MaterialTheme.colorScheme.error) + Text( + viewModel.error ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.Button(onClick = { viewModel.load(force = true) }) { + Text("Retry") } } } - else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer) + else -> { + val lastOpened = remember { mutableIntStateOf(-1) } + var focusedRow by remember { mutableIntStateOf(0) } + + Column(Modifier.fillMaxSize()) { + if (viewModel.userChannels.isNotEmpty()) { + // row 1: the user's own channels; header = account icon + username + RowHeader( + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.username ?: "You") + " — your channels", + ) + ChannelCardRow( + viewModel.userChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = true, + onLeftAtStart = onOpenDrawer, + ) + } + + if (viewModel.localChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.localChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + + if (viewModel.followedChannels.isNotEmpty()) { + RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) + ChannelCardRow( + viewModel.followedChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = false, + onLeftAtStart = {}, + ) + } + } } } } +/** Row header: user icon + username, or plain label. */ @Composable -private fun ChannelsList( - items: List, - viewModel: ChannelsViewModel, +private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (avatar != null) { + AsyncImage( + model = absolute(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF222222)), + ) + } + Text(label, style = MaterialTheme.typography.titleMedium, color = Color.White) + } +} + +private fun absolute(pathOrUrl: String): String = + if (pathOrUrl.startsWith("http")) pathOrUrl + else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl + +/** Horizontal row of channel cards; eases newly focused cards into view. */ +@Composable +private fun ChannelCardRow( + channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, + firstFocused: Boolean, + onLeftAtStart: () -> Unit, ) { - val firstCardFocus = remember { androidx.compose.ui.focus.FocusRequester() } - LaunchedEffect(items.isNotEmpty()) { - if (items.isNotEmpty()) runCatching { firstCardFocus.requestFocus() } + val rowState = androidx.compose.foundation.lazy.rememberLazyListState() + val focusRequesters = remember { mutableMapOf() } + var focusedIndex by remember { mutableIntStateOf(-1) } + var clickedIndex by remember { mutableIntStateOf(-1) } + + LaunchedEffect(focusedIndex) { + if (focusedIndex > 0) { + val visible = rowState.layoutInfo.visibleItemsInfo + val target = visible.firstOrNull { it.index == focusedIndex } + val notFullyVisible = target == null || + target.offset < 0 || + target.offset + target.size > rowState.layoutInfo.viewportEndOffset + if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) + } } - LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { - itemsIndexed(items) { index, channel -> + if (firstFocused) { + LaunchedEffect(channels.isNotEmpty()) { + if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + } + } + + androidx.compose.foundation.lazy.LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(channels) { index, channel -> + val fr = remember { FocusRequester() } + focusRequesters[index] = fr ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onLeftAtStart = if (index == 0) onOpenDrawer else null, - modifier = if (index == 0) Modifier.focusRequester(firstCardFocus) else Modifier, + modifier = Modifier.focusRequester(fr), ) - if (index >= items.size - PagedListViewModel.LOOKAHEAD) { - LaunchedEffect(index) { viewModel.loadMore() } - } - } - if (!viewModel.exhausted) { - item { - Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } } } } +/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */ @Composable -private fun ChannelCard( +fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, - onLeftAtStart: (() -> Unit)? = null, modifier: Modifier = Modifier, + onLeftAtStart: (() -> Unit)? = null, ) { + var cardFocused by remember { mutableStateOf(false) } Card( onClick = onClick, modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - colors = CardDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + .width(rowCardWidth()) + .padding(6.dp) + .onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && + onLeftAtStart != null + ) { + onLeftAtStart() + true + } else false + }, + colors = CardDefaults.colors( + containerColor = UnfocusedCardBg, + contentColor = Color.White, + focusedContainerColor = Color.White, + focusedContentColor = Color(0xFF101010), + ), + scale = CardDefaults.scale(focusedScale = 1f), + border = CardDefaults.border( + focusedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + pressedBorder = Border(border = BorderStroke(3.dp, Color.White), shape = ContainerShape), + ), ) { - 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) - - val avReq = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) - .data(url) - .size(160) - .build() - AsyncImage( - model = avReq, - contentDescription = channel.displayName, - contentScale = ContentScale.Crop, - modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)), - ) + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(Color(0xFF222222)), + ) { + val banner = channel.banners.maxByOrNull { it.width } + if (banner != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(banner.fileUrl ?: banner.path)) + .size(480) + .build(), + contentDescription = channel.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) + } + val avatar = channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(absolute(avatar.fileUrl ?: avatar.path)) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF333333)), + ) + } } - Column(modifier = Modifier.padding(start = 12.dp)) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { Text( text = channel.displayName.ifBlank { channel.name }, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, + minLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + color = if (cardFocused) Color(0xFF101010) else Color.White, ) Text( - text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos", + text = channel.host + " · " + channel.videosCount + " videos · " + + channel.followersCount + " followers", style = MaterialTheme.typography.bodySmall, - color = Color(0xFF8A8A8A), + color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 290d4ee..a68bfac 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -43,7 +43,7 @@ import net.jeena.jtubetv.ui.screens.list.VideoCard /** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */ @Composable -private fun rowCardWidth(): androidx.compose.ui.unit.Dp { +fun rowCardWidth(): androidx.compose.ui.unit.Dp { val windowWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp return ((windowWidth / 4.35f) - 10).dp } @@ -243,7 +243,7 @@ fun HomeRowsScreen( * the abrupt jump the built-in lazy scroll produces. */ @Composable -private fun FocusScrollRow( +fun FocusScrollRow( videos: List, progressOf: (String) -> Float?, onOpenVideo: (VideoDto) -> Unit, 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 index 491eaf1..a602d88 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -40,13 +40,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon @@ -106,7 +105,25 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi drawerOpen = false } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { ke -> + // Keyboard/scrcpy: Esc behaves like BACK for drawer + drilldowns + // (the player consumes Esc itself when playing). + val native = ke.nativeKeyEvent + if (native.action == android.view.KeyEvent.ACTION_DOWN && + native.keyCode == android.view.KeyEvent.KEYCODE_ESCAPE && + playingVideo == null + ) { + when { + drawerOpen -> { drawerOpen = false; true } + channelDetails != null -> { channelDetails = null; true } + else -> false + } + } else false + }, + ) { androidx.compose.runtime.key(epoch) { Column { // SmartTube-style top bar: red round search + avatar opening the menu. 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 index 0297c1a..6c21ee1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -324,6 +324,11 @@ private fun PlayerSurface( val handleKey: (KeyEvent) -> Boolean = { event -> if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { + // scrcpy/keyboard users: Esc = back (Ctrl+B relies on the mod binding) + KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_BACK -> { + onBack() + true + } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { player.playWhenReady = !player.playWhenReady true From b7fbf757ddaac3d5d67bac0841339345456bc406 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 01:15:51 +0000 Subject: [PATCH 25/53] scripts: bridge-emulator.sh (host) + image prefetching Perf work for the TV FPS complaint: every page that loads from the API also warms Coil's disk/memory caches (video thumbnails at the same 480x270 the card renders, channel banners/avatars) so rows scroll without popping in over the network. Prefetch keys match render keys exactly so cache hits are guaranteed. bridge-emulator.sh runs OUTSIDE the container on the emulator host: boots jtubesc if needed (ADB_VENDOR_KEYS whitelists the host adb key), installs/starts JTube TV and binds :5557 -> adbd 5601 so the container and LAN can drive it. Run it once in your rutherford shell. --- .../main/java/net/jeena/jtubetv/JTubeApp.kt | 3 ++ .../jeena/jtubetv/ui/list/ImagePrefetcher.kt | 53 +++++++++++++++++++ .../jtubetv/ui/list/PagedListViewModel.kt | 4 ++ .../jtubetv/ui/list/VideoListViewModel.kt | 11 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 9 +++- scripts/bridge-emulator.sh | 42 +++++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt create mode 100755 scripts/bridge-emulator.sh diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index ec4c71a..fe13971 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -13,8 +13,11 @@ object AppGraph { private set lateinit var tokenStore: TokenStore private set + var appContext: android.content.Context? = null + private set fun init(context: Application) { + appContext = context tokenStore = TokenStore(context) repository = PeerTubeRepository(tokenStore, PeerTubeNetwork(tokenStore)) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt new file mode 100644 index 0000000..e29ce93 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt @@ -0,0 +1,53 @@ +package net.jeena.jtubetv.ui.list + +import android.content.Context +import coil.imageLoader +import coil.request.ImageRequest +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.VideoChannelDto +import net.jeena.jtubetv.data.api.VideoDto + +/** + * Warms Coil's caches for images that are already loaded from the API but not yet + * visible further down a row/list, so they pop in instantly instead of popping in + * over the network while scrolling. + * + * Uses the same request shape (size) as the visible [VideoCard]/[ChannelCard] + * renderers so the memory cache keys match exactly. + */ +object ImagePrefetcher { + + private val enqueued = HashSet() + + fun videoThumbnails(videos: List, context: Context = AppGraph.appContext!!) { + for (video in videos) { + val path = video.thumbnailPath?.takeIf { it.isNotBlank() } ?: continue + enqueue(context, AppGraph.repository.absoluteUrl(path), 480, 270) + } + } + + fun channelArt(channels: List, context: Context = AppGraph.appContext!!) { + for (channel in channels) { + val banner = channel.banners.maxByOrNull { it.width } + if (banner != null) enqueue(context, absolute(banner), 480, 270) + val avatar = channel.avatars.maxByOrNull { it.width } + if (avatar != null) enqueue(context, absolute(avatar), 80, 80) + } + } + + private fun absolute(a: Avatar): String = + (a.fileUrl ?: a.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + + private fun enqueue(context: Context, url: String, width: Int, height: Int) { + if (!enqueued.add(url)) return + val request = ImageRequest.Builder(context) + .data(url) + .size(width, height) + .build() + context.imageLoader.enqueue(request) + } +} \ 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 index c96c5bc..3429a76 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -27,6 +27,9 @@ abstract class PagedListViewModel : ViewModel() { protected abstract suspend fun fetch(start: Long, count: Int): Result> + /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ + protected open fun onChunkLoaded(chunk: List) {} + fun loadMore(force: Boolean = false) { if (inFlight || (!force && exhausted)) return inFlight = true @@ -39,6 +42,7 @@ abstract class PagedListViewModel : ViewModel() { exhausted = chunk.size < pageSize items = items + chunk loadingInitial = false + onChunkLoaded(chunk) }, onFailure = { e -> if (items.isEmpty()) { 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 index 90264f3..33bf4aa 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -30,6 +30,17 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + override fun onChunkLoaded(chunk: List) { + ImagePrefetcher.videoThumbnails( + chunk.mapNotNull { + when (it) { + is VideoListItem.Video -> it.video + is VideoListItem.HistoryEntry -> it.history.video + } + } + ) + } + override suspend fun fetch(start: Long, count: Int): Result> { return when (val s = source) { is ListSource.LocalVideos -> 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 index 382cdb5..b7a065d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -109,6 +109,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { userChannels = (userChannels + mine).distinctBy { it.name to it.host } localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a68bfac..5867b5a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -65,7 +65,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private fun fetchSubs() { viewModelScope.launch { - subItems = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + subItems = subs + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) } } @@ -108,6 +110,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( }.awaitAll() continueItems = items continueLoading = false + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) } } @@ -122,6 +125,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( /** Paged "New videos" row content. */ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + override fun onChunkLoaded(chunk: List) { + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) + } + override suspend fun fetch(start: Long, count: Int): Result> = repository.localVideos(start, count).map { it.data } diff --git a/scripts/bridge-emulator.sh b/scripts/bridge-emulator.sh new file mode 100755 index 0000000..5fc8ed2 --- /dev/null +++ b/scripts/bridge-emulator.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# bridge-emulator.sh — (host-side) expose the emulator's adbd to the LAN so agents +# inside the container/other machines can use it: binds :5557 -> 127.0.0.1:5601. +# The emulator console transport (emulator-5600) stays local-only; this bridges the +# adbd TCP port 5601. +set -euo pipefail +ADBD_PORT=5601 +BRIDGE_PORT=5557 + +# start emulator if not running +ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb" +if ! "$ADB" devices | grep -q "emulator-5600.*device"; then + echo "emulator-5600 not running — starting it…" + ADB_VENDOR_KEYS="$HOME/.android/adbkey" setsid nohup \ + "${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/emulator/emulator" -avd jtubesc -port 5600 \ + -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim \ + > /tmp/jtube-emu.log 2>&1 < /dev/null & + echo "waiting for boot (log: /tmp/jtube-emu.log)…" + for i in $(seq 1 60); do + ok=$("$ADB" -s emulator-5600 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } + sleep 5 + done +fi + +# keep JTube TV installed + running +APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" +if [ -f "$APK" ]; then + "$ADB" -s emulator-5600 install -r "$APK" >/dev/null && echo "JTube TV installed" + "$ADB" -s emulator-5600 shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" +fi + +# the bridge (bind all LAN addresses) +if ! ss -tln 2>/dev/null | grep -q ":$BRIDGE_PORT"; then + setsid nohup socat TCP-LISTEN:$BRIDGE_PORT,bind=0.0.0.0,fork,reuseaddr \ + TCP:127.0.0.1:$ADBD_PORT > /tmp/jtube-bridge.log 2>&1 < /dev/null & + echo "bridge: :$BRIDGE_PORT -> 127.0.0.1:$ADBD_PORT (pid $!)" +fi + +echo "From this host: adb connect localhost:$BRIDGE_PORT" +echo "From other machines: adb connect rutherford:$BRIDGE_PORT" \ No newline at end of file From 9cbac3ce00cc8b0caef58f580761416defccf840 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 01:15:51 +0000 Subject: [PATCH 26/53] scripts: bridge-emulator.sh (host) + image prefetching Perf work for the TV FPS complaint: every page that loads from the API also warms Coil's disk/memory caches (video thumbnails at the same 480x270 the card renders, channel banners/avatars) so rows scroll without popping in over the network. Prefetch keys match render keys exactly so cache hits are guaranteed. bridge-emulator.sh runs OUTSIDE the container on the emulator host: boots jtubesc if needed (ADB_VENDOR_KEYS whitelists the host adb key), installs/starts JTube TV and binds :5557 -> adbd 5601 so the container and LAN can drive it. Run it once in your rutherford shell. --- .../main/java/net/jeena/jtubetv/JTubeApp.kt | 3 ++ .../jeena/jtubetv/ui/list/ImagePrefetcher.kt | 53 +++++++++++++++++++ .../jtubetv/ui/list/PagedListViewModel.kt | 4 ++ .../jtubetv/ui/list/VideoListViewModel.kt | 11 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 1 + .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 9 +++- scripts/bridge-emulator.sh | 42 +++++++++++++++ 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt create mode 100755 scripts/bridge-emulator.sh diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index ec4c71a..fe13971 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -13,8 +13,11 @@ object AppGraph { private set lateinit var tokenStore: TokenStore private set + var appContext: android.content.Context? = null + private set fun init(context: Application) { + appContext = context tokenStore = TokenStore(context) repository = PeerTubeRepository(tokenStore, PeerTubeNetwork(tokenStore)) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt new file mode 100644 index 0000000..e29ce93 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/ImagePrefetcher.kt @@ -0,0 +1,53 @@ +package net.jeena.jtubetv.ui.list + +import android.content.Context +import coil.imageLoader +import coil.request.ImageRequest +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.VideoChannelDto +import net.jeena.jtubetv.data.api.VideoDto + +/** + * Warms Coil's caches for images that are already loaded from the API but not yet + * visible further down a row/list, so they pop in instantly instead of popping in + * over the network while scrolling. + * + * Uses the same request shape (size) as the visible [VideoCard]/[ChannelCard] + * renderers so the memory cache keys match exactly. + */ +object ImagePrefetcher { + + private val enqueued = HashSet() + + fun videoThumbnails(videos: List, context: Context = AppGraph.appContext!!) { + for (video in videos) { + val path = video.thumbnailPath?.takeIf { it.isNotBlank() } ?: continue + enqueue(context, AppGraph.repository.absoluteUrl(path), 480, 270) + } + } + + fun channelArt(channels: List, context: Context = AppGraph.appContext!!) { + for (channel in channels) { + val banner = channel.banners.maxByOrNull { it.width } + if (banner != null) enqueue(context, absolute(banner), 480, 270) + val avatar = channel.avatars.maxByOrNull { it.width } + if (avatar != null) enqueue(context, absolute(avatar), 80, 80) + } + } + + private fun absolute(a: Avatar): String = + (a.fileUrl ?: a.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + + private fun enqueue(context: Context, url: String, width: Int, height: Int) { + if (!enqueued.add(url)) return + val request = ImageRequest.Builder(context) + .data(url) + .size(width, height) + .build() + context.imageLoader.enqueue(request) + } +} \ 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 index c96c5bc..3429a76 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/PagedListViewModel.kt @@ -27,6 +27,9 @@ abstract class PagedListViewModel : ViewModel() { protected abstract suspend fun fetch(start: Long, count: Int): Result> + /** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */ + protected open fun onChunkLoaded(chunk: List) {} + fun loadMore(force: Boolean = false) { if (inFlight || (!force && exhausted)) return inFlight = true @@ -39,6 +42,7 @@ abstract class PagedListViewModel : ViewModel() { exhausted = chunk.size < pageSize items = items + chunk loadingInitial = false + onChunkLoaded(chunk) }, onFailure = { e -> if (items.isEmpty()) { 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 index 90264f3..33bf4aa 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -30,6 +30,17 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + override fun onChunkLoaded(chunk: List) { + ImagePrefetcher.videoThumbnails( + chunk.mapNotNull { + when (it) { + is VideoListItem.Video -> it.video + is VideoListItem.HistoryEntry -> it.history.video + } + } + ) + } + override suspend fun fetch(start: Long, count: Int): Result> { return when (val s = source) { is ListSource.LocalVideos -> 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 index 382cdb5..b7a065d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -109,6 +109,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { userChannels = (userChannels + mine).distinctBy { it.name to it.host } localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index a68bfac..5867b5a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -65,7 +65,9 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private fun fetchSubs() { viewModelScope.launch { - subItems = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList() + subItems = subs + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) } } @@ -108,6 +110,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( }.awaitAll() continueItems = items continueLoading = false + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) } } @@ -122,6 +125,10 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( /** Paged "New videos" row content. */ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + override fun onChunkLoaded(chunk: List) { + net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) + } + override suspend fun fetch(start: Long, count: Int): Result> = repository.localVideos(start, count).map { it.data } diff --git a/scripts/bridge-emulator.sh b/scripts/bridge-emulator.sh new file mode 100755 index 0000000..5fc8ed2 --- /dev/null +++ b/scripts/bridge-emulator.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# bridge-emulator.sh — (host-side) expose the emulator's adbd to the LAN so agents +# inside the container/other machines can use it: binds :5557 -> 127.0.0.1:5601. +# The emulator console transport (emulator-5600) stays local-only; this bridges the +# adbd TCP port 5601. +set -euo pipefail +ADBD_PORT=5601 +BRIDGE_PORT=5557 + +# start emulator if not running +ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb" +if ! "$ADB" devices | grep -q "emulator-5600.*device"; then + echo "emulator-5600 not running — starting it…" + ADB_VENDOR_KEYS="$HOME/.android/adbkey" setsid nohup \ + "${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/emulator/emulator" -avd jtubesc -port 5600 \ + -no-window -gpu swiftshader_indirect -no-snapshot -no-audio -no-boot-anim \ + > /tmp/jtube-emu.log 2>&1 < /dev/null & + echo "waiting for boot (log: /tmp/jtube-emu.log)…" + for i in $(seq 1 60); do + ok=$("$ADB" -s emulator-5600 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [ "$ok" = "1" ] && { echo "BOOTED"; break; } + sleep 5 + done +fi + +# keep JTube TV installed + running +APK="$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" +if [ -f "$APK" ]; then + "$ADB" -s emulator-5600 install -r "$APK" >/dev/null && echo "JTube TV installed" + "$ADB" -s emulator-5600 shell am start -n net.jeena.jtubetv/.MainActivity && echo "app started" +fi + +# the bridge (bind all LAN addresses) +if ! ss -tln 2>/dev/null | grep -q ":$BRIDGE_PORT"; then + setsid nohup socat TCP-LISTEN:$BRIDGE_PORT,bind=0.0.0.0,fork,reuseaddr \ + TCP:127.0.0.1:$ADBD_PORT > /tmp/jtube-bridge.log 2>&1 < /dev/null & + echo "bridge: :$BRIDGE_PORT -> 127.0.0.1:$ADBD_PORT (pid $!)" +fi + +echo "From this host: adb connect localhost:$BRIDGE_PORT" +echo "From other machines: adb connect rutherford:$BRIDGE_PORT" \ No newline at end of file From e762c03a9c338838b3bbeb854cea3115179a7fc4 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:14:32 +0000 Subject: [PATCH 27/53] ui: Real account button (avatar + display name), stable list keys The top bar now mirrors SmartTube: a 44dp circle with the actual user avatar (persisted at login alongside the display name; falls back to the person icon) and the display name next to it. Scrolling jank: add stable keys to grids and rows (item/channel ids) - without keys Compose rebinds the whole list during scroll and re-fires every AsyncImage + focus request, which is the realistic culprit for the 2fps vertical scrolling on the TV. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 9 +++- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 1 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 9 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 2 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 46 ++++++++++++++----- .../ui/screens/list/VideoListScreen.kt | 8 +++- 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 93a0e9e..64d35bb 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -86,7 +86,14 @@ class PeerTubeRepository( .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) .build() .create(PeerTubeApi::class.java) - store.accountName = meApi.me().username + val me = meApi.me() + store.accountName = me.username + store.userDisplayName = me.displayName ?: me.username + me.avatars.maxByOrNull { it.width }?.let { avatar -> + store.userAvatarUrl = avatar.fileUrl + ?.takeIf { url -> url.startsWith("http") } + ?: apiUrlOf(base) + avatar.path + } } catch (_: Exception) { store.accountName = username.substringBefore('@') // best effort fallback } 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 index 231aa31..41cf173 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -137,5 +137,6 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val displayName: String? = null, val avatars: List = emptyList(), ) \ 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 index 2620641..0b006ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -48,6 +48,15 @@ class TokenStore(context: Context) { get() = prefs.getString("refreshToken", null) private set(value) = prefs.edit().putString("refreshToken", value).apply() + /** Display name + avatar for the account button (from users/me at login). */ + var userDisplayName: String? + get() = prefs.getString("userDisplayName", null) + set(value) = prefs.edit().putString("userDisplayName", value).apply() + + var userAvatarUrl: String? + get() = prefs.getString("userAvatarUrl", null) + set(value) = prefs.edit().putString("userAvatarUrl", value).apply() + /** Real account name from users/me — may differ from the login input (email). */ var accountName: String? get() = prefs.getString("accountName", null) 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 index b7a065d..00ca382 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -262,7 +262,7 @@ private fun ChannelCardRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(channels) { index, channel -> + itemsIndexed(channels, key = { _, c -> c.name + "@" + c.host }) { index, channel -> val fr = remember { FocusRequester() } focusRequesters[index] = fr ChannelCard( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 5867b5a..625a6d0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -299,7 +299,7 @@ fun FocusScrollRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(videos) { index, video -> + itemsIndexed(videos, key = { _, video -> "v" + video.id }) { index, video -> val fr = remember { FocusRequester() } focusRequesters[index] = fr VideoCard( 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 index a602d88..b2de95a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -239,31 +239,53 @@ private fun TopBar( desc = "Search", onClick = onSearchClick, ) - // "Who is logged in" — account button with the username (SmartTube shows the user). + // "Who is logged in" — account button: avatar circle + display name (SmartTube). val accountFocused = remember { mutableStateOf(false) } Row( modifier = Modifier - .clip(CircleShape) .onFocusChanged { accountFocused.value = it.isFocused } - .background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent) + .background(Color.Transparent) .then( if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) else Modifier ) .focusable() - .clickable(onClick = onAccountClick) - .padding(6.dp), + .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = "Menu", - tint = Color(0xFFCCCCCC), - ) + val avatarUrl = AppGraph.tokenStore.userAvatarUrl + if (avatarUrl != null) { + coil.compose.AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(avatarUrl) + .size(96) + .build(), + contentDescription = "Account", + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + ) + } else { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Account", + tint = Color(0xFFCCCCCC), + ) + } + } Text( - text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", - color = Color(0xFFCCCCCC), + text = AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFB0B0B0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp), ) 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 index 8e3570e..1cfe009 100644 --- 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 @@ -124,7 +124,7 @@ fun VideoListScreen( state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { - itemsIndexed(items) { index, item -> + itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.Video -> item.video @@ -189,6 +189,12 @@ private fun SearchHeader(viewModel: VideoListViewModel) { } } +private fun itemStableKey(item: VideoListItem): String = + when (item) { + is VideoListItem.Video -> "v" + item.video.id + is VideoListItem.HistoryEntry -> "h" + item.history.video.id + } + private fun sourceKey(source: ListSource): String = when (source) { is ListSource.Channel -> "channel:${source.handle}" else -> source::class.java.simpleName From c96d628fec408f3a6f66f2c780f59724a061111f Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:14:32 +0000 Subject: [PATCH 28/53] ui: Real account button (avatar + display name), stable list keys The top bar now mirrors SmartTube: a 44dp circle with the actual user avatar (persisted at login alongside the display name; falls back to the person icon) and the display name next to it. Scrolling jank: add stable keys to grids and rows (item/channel ids) - without keys Compose rebinds the whole list during scroll and re-fires every AsyncImage + focus request, which is the realistic culprit for the 2fps vertical scrolling on the TV. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 9 +++- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 1 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 9 ++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 2 +- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 46 ++++++++++++++----- .../ui/screens/list/VideoListScreen.kt | 8 +++- 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 93a0e9e..64d35bb 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -86,7 +86,14 @@ class PeerTubeRepository( .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) .build() .create(PeerTubeApi::class.java) - store.accountName = meApi.me().username + val me = meApi.me() + store.accountName = me.username + store.userDisplayName = me.displayName ?: me.username + me.avatars.maxByOrNull { it.width }?.let { avatar -> + store.userAvatarUrl = avatar.fileUrl + ?.takeIf { url -> url.startsWith("http") } + ?: apiUrlOf(base) + avatar.path + } } catch (_: Exception) { store.accountName = username.substringBefore('@') // best effort fallback } 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 index 231aa31..41cf173 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -137,5 +137,6 @@ data class VideoTokenFiles( @Serializable data class UserDto( val username: String, + val displayName: String? = null, val avatars: List = emptyList(), ) \ 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 index 2620641..0b006ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -48,6 +48,15 @@ class TokenStore(context: Context) { get() = prefs.getString("refreshToken", null) private set(value) = prefs.edit().putString("refreshToken", value).apply() + /** Display name + avatar for the account button (from users/me at login). */ + var userDisplayName: String? + get() = prefs.getString("userDisplayName", null) + set(value) = prefs.edit().putString("userDisplayName", value).apply() + + var userAvatarUrl: String? + get() = prefs.getString("userAvatarUrl", null) + set(value) = prefs.edit().putString("userAvatarUrl", value).apply() + /** Real account name from users/me — may differ from the login input (email). */ var accountName: String? get() = prefs.getString("accountName", null) 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 index b7a065d..00ca382 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -262,7 +262,7 @@ private fun ChannelCardRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(channels) { index, channel -> + itemsIndexed(channels, key = { _, c -> c.name + "@" + c.host }) { index, channel -> val fr = remember { FocusRequester() } focusRequesters[index] = fr ChannelCard( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 5867b5a..625a6d0 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -299,7 +299,7 @@ fun FocusScrollRow( contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - itemsIndexed(videos) { index, video -> + itemsIndexed(videos, key = { _, video -> "v" + video.id }) { index, video -> val fr = remember { FocusRequester() } focusRequesters[index] = fr VideoCard( 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 index a602d88..b2de95a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -239,31 +239,53 @@ private fun TopBar( desc = "Search", onClick = onSearchClick, ) - // "Who is logged in" — account button with the username (SmartTube shows the user). + // "Who is logged in" — account button: avatar circle + display name (SmartTube). val accountFocused = remember { mutableStateOf(false) } Row( modifier = Modifier - .clip(CircleShape) .onFocusChanged { accountFocused.value = it.isFocused } - .background(if (accountFocused.value) Color(0x33FFFFFF) else Color.Transparent) + .background(Color.Transparent) .then( if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape) else Modifier ) .focusable() - .clickable(onClick = onAccountClick) - .padding(6.dp), + .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = "Menu", - tint = Color(0xFFCCCCCC), - ) + val avatarUrl = AppGraph.tokenStore.userAvatarUrl + if (avatarUrl != null) { + coil.compose.AsyncImage( + model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + .data(avatarUrl) + .size(96) + .build(), + contentDescription = "Account", + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + ) + } else { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0xFF262626)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Account", + tint = Color(0xFFCCCCCC), + ) + } + } Text( - text = AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", - color = Color(0xFFCCCCCC), + text = AppGraph.tokenStore.userDisplayName + ?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account", + color = Color(0xFFB0B0B0), style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp), ) 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 index 8e3570e..1cfe009 100644 --- 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 @@ -124,7 +124,7 @@ fun VideoListScreen( state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { - itemsIndexed(items) { index, item -> + itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> val video: VideoDto = when (item) { is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.Video -> item.video @@ -189,6 +189,12 @@ private fun SearchHeader(viewModel: VideoListViewModel) { } } +private fun itemStableKey(item: VideoListItem): String = + when (item) { + is VideoListItem.Video -> "v" + item.video.id + is VideoListItem.HistoryEntry -> "h" + item.history.video.id + } + private fun sourceKey(source: ListSource): String = when (source) { is ListSource.Channel -> "channel:${source.handle}" else -> source::class.java.simpleName From 7bd0a386f15252f20ad8fa0a59c00dcd841043b5 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:17:12 +0000 Subject: [PATCH 29/53] ui: Fix recompose loop during scroll (2fps vertical) Focus requesters were stored in snapshot-state maps and written during composition of every list item: each write invalidated the reading scope, which recomposed the (whole page of) items, wrote again, ... This feedback loop is why vertical scrolling ran at ~2fps on the TV while the same build scrolled fine in the emulator's short bursts. Plain HashMaps kill the loop - focus handling needs no recomposition. --- .../main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt | 2 +- app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 00ca382..fa76b3a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -237,7 +237,7 @@ private fun ChannelCardRow( onLeftAtStart: () -> Unit, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() - val focusRequesters = remember { mutableMapOf() } + val focusRequesters = remember { HashMap() } var focusedIndex by remember { mutableIntStateOf(-1) } var clickedIndex by remember { mutableIntStateOf(-1) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 625a6d0..69dafd1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -260,7 +260,7 @@ fun FocusScrollRow( afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() - val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf() } + val focusRequesters = remember { HashMap() } var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } // BACK from playback: scroll to + focus the card that was opened (user request). LaunchedEffect(restoreToken) { From ff4a88a50427ddf1f80d47bd6875cab5898cb087 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:17:12 +0000 Subject: [PATCH 30/53] ui: Fix recompose loop during scroll (2fps vertical) Focus requesters were stored in snapshot-state maps and written during composition of every list item: each write invalidated the reading scope, which recomposed the (whole page of) items, wrote again, ... This feedback loop is why vertical scrolling ran at ~2fps on the TV while the same build scrolled fine in the emulator's short bursts. Plain HashMaps kill the loop - focus handling needs no recomposition. --- .../main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt | 2 +- app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 00ca382..fa76b3a 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -237,7 +237,7 @@ private fun ChannelCardRow( onLeftAtStart: () -> Unit, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() - val focusRequesters = remember { mutableMapOf() } + val focusRequesters = remember { HashMap() } var focusedIndex by remember { mutableIntStateOf(-1) } var clickedIndex by remember { mutableIntStateOf(-1) } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 625a6d0..69dafd1 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -260,7 +260,7 @@ fun FocusScrollRow( afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() - val focusRequesters = remember { androidx.compose.runtime.mutableStateMapOf() } + val focusRequesters = remember { HashMap() } var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) } // BACK from playback: scroll to + focus the card that was opened (user request). LaunchedEffect(restoreToken) { From 583543a26b7abcd9373095d5f09fdbbd93b3dc17 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:42:02 +0000 Subject: [PATCH 31/53] channels: Per-user rows, empty-channels row, redesigned stats card, subs detail Channels now render as SmartTube rows and scroll vertically (LazyColumn - the unscrollable Column hid rows 2+). Local channels group one row per ownerAccount, sorted by total video count, with all zero-video channels merged into a final 'No videos yet' row. Followed-instance channels group per platform. The channel card matches the video-card language: banner center-cropped, round avatar bottom-left, channel name, 'owner display name - platform display name' (instance names via /config, local cached at login; no domain strings), and an icon stat line (videos | subscribers | plays); counts load per channel in the background since the listing endpoint doesn't carry them (verified on the live API). Subscriptions destination switches to per-channel rows, matching the Home card size. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 42 ++++ .../java/net/jeena/jtubetv/data/api/Dtos.kt | 26 +++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 3 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 5 + .../jtubetv/ui/screens/ChannelsScreen.kt | 217 ++++++++++++++---- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 36 +-- .../jtubetv/ui/screens/SubscriptionsScreen.kt | 168 ++++++++++++++ 7 files changed, 435 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 64d35bb..5079c0d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -2,7 +2,9 @@ package net.jeena.jtubetv.data import kotlinx.serialization.json.Json import net.jeena.jtubetv.data.api.HistoryElement +import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.OAuthClient +import net.jeena.jtubetv.data.api.ServerConfigDto import net.jeena.jtubetv.data.api.PeerTubeApi import net.jeena.jtubetv.data.api.TokenResponse import net.jeena.jtubetv.data.api.UserDto @@ -86,6 +88,9 @@ class PeerTubeRepository( .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) .build() .create(PeerTubeApi::class.java) + store.instanceName = try { + meApi.serverConfig().instance.name + } catch (_: Exception) { null } val me = meApi.me() store.accountName = me.username store.userDisplayName = me.displayName ?: me.username @@ -101,6 +106,43 @@ class PeerTubeRepository( suspend fun me(): Result = runCatching { network.api.me() } + suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } + + /** Display name of a (possibly foreign) instance, via that instance's /config. */ + suspend fun instanceName(rawHost: String): Result = runCatching { + val base = "https://" + rawHost + val api = unauthenticatedApiFor(base) + api.serverConfig().instance.name ?: rawHost + } + + /** + * videosCount + total plays for a channel (listing DTOs don't carry them). + * Sums views over up to [maxPages] video pages. + */ + suspend fun channelStats(handle: String, maxPages: Int = 4): Result = + runCatching { + var start = 0L + var videos = 0L + var plays = 0L + var total: Long? = null + repeat(maxPages) { + val page = network.api.channelVideos(handle, start, 25) + if (total == null) total = page.total + plays += page.data.sumOf { it.views } + start += page.data.size + videos = total ?: videos + } + net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + } + + private fun unauthenticatedApiFor(base: String): PeerTubeApi = + Retrofit.Builder() + .baseUrl(apiUrlOf(base) + "/") + .client(plainOkHttp()) + .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) + .build() + .create(PeerTubeApi::class.java) + // ---- video lists ------------------------------------------------------- suspend fun localVideos(start: Long, count: Int, search: String? = null): Result = 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 index 41cf173..b807ebc 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -60,6 +60,14 @@ data class VideoListResponse( val data: List, ) +@Serializable +data class ChannelOwnerAccount( + val name: String, + val host: String? = null, + @SerialName("displayName") val displayName: String? = null, + val avatars: List = emptyList(), +) + @Serializable data class VideoChannelDto( val name: String, @@ -70,6 +78,7 @@ data class VideoChannelDto( val banners: List = emptyList(), val videosCount: Long = 0, val followersCount: Long = 0, + val ownerAccount: ChannelOwnerAccount? = null, ) @Serializable @@ -134,6 +143,23 @@ data class VideoTokenFiles( val expires: String? = null, ) +@Serializable +data class ServerConfigDto( + val instance: InstanceInfoDto, +) + +@Serializable +data class InstanceInfoDto( + val name: String? = null, + val shortDescription: String? = null, +) + +/** Per-channel stats not available in the listing; fetched on demand. */ +data class ChannelStats( + val videos: Long, + val plays: Long, +) + @Serializable data class UserDto( val username: String, 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 index 76dd8e2..4c505ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -112,4 +112,7 @@ interface PeerTubeApi { @GET("users/me") suspend fun me(): UserDto + + @GET("config") + suspend fun serverConfig(): ServerConfigDto } \ 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 index 0b006ce..054da4d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -53,6 +53,11 @@ class TokenStore(context: Context) { get() = prefs.getString("userDisplayName", null) set(value) = prefs.edit().putString("userDisplayName", value).apply() + /** Human-friendly instance name (e.g. "JTube") for card subtitles. */ + var instanceName: String? + get() = prefs.getString("instanceName", null) + set(value) = prefs.edit().putString("instanceName", value).apply() + var userAvatarUrl: String? get() = prefs.getString("userAvatarUrl", null) set(value) = prefs.edit().putString("userAvatarUrl", value).apply() 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 index fa76b3a..707db29 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -53,6 +57,17 @@ import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg +/** One row of channels belonging to one user (avatar + display name as header). */ +data class UserGroup( + val label: String?, + val avatar: Avatar?, + val videos: Long, + val rows: List, +) { + /** Sort weight: users with videos first (more videos first), empty channels last. */ + fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 +} + /** * Channels as SmartTube-style rows: * 1. "Your channels" — header with account avatar + username, then own channel cards. @@ -70,13 +85,28 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private set var localChannels by mutableStateOf>(emptyList()) private set - var followedChannels by mutableStateOf>(emptyList()) + + /** One row per (local) user, sorted by total video count; empties last. */ + var localUserRows by mutableStateOf>(emptyList()) private set + + /** Channels from instances this server follows, grouped by their instance. */ + var followedRows by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) private set var error by mutableStateOf(null) private set + /** "name@host" -> videos + plays; filled in the background. */ + var stats by mutableStateOf>(emptyMap()) + private set + + /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ + var platformNames by mutableStateOf>(emptyMap()) + private set + private val repo: PeerTubeRepository = AppGraph.repository fun load(force: Boolean = false) { @@ -85,13 +115,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { error = null viewModelScope.launch { try { - // row header (avatar + real username) user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() - // enumerate the whole channel list, split by host client-side val all = mutableListOf() var start = 0L var total: Long? = null @@ -102,14 +130,56 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 } + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') val ownNames = userChannels.map { c -> c.name }.toSet() val mine = all.filter { it.host == host && it.name in ownNames } val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } - followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + localChannels = rest.filter { it.host == host } + net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) + + // platform display names (local cached in prefs; foreign lazy) + platformNames = mapOf( + (host ?: "") to (store.instanceName ?: host ?: ""), + ) + val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") + for (fh in foreignHosts) { + val name = repo.instanceName(fh).getOrNull() ?: continue + platformNames = platformNames + (fh to name) + } + + // video + play counts per channel, one tiny listing call per channel + for (channel in (userChannels + localChannels)) { + val key = channel.name + "@" + channel.host + val cs = repo.channelStats(key).getOrNull() ?: continue + stats = stats + (key to cs) + } + // group LOCAL channels by owner (user): row per user, videos desc, + // channels with zero videos collected into a single last row. + val locals = rest.filter { it.host == host } + val withVideos = locals.filter { it.videosCount > 0 } + val empty = locals.filter { it.videosCount <= 0 } + val byOwner = withVideos.groupBy { ownerKey(it) } + localUserRows = + byOwner.values + .map { row -> UserGroup( + label = rowLabel(row), + avatar = rowAvatar(row), + videos = rowTotal(row), + rows = row.sortedByDescending { it.videosCount }, + ) } + .filter { it.label != null } + .sortedByDescending { it.rowWeight() } + if (empty.isNotEmpty()) { + localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) + } + followedRows = rest.filter { it.host != host } + .groupBy { it.host ?: "?" } + .values + .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } + .sortedByDescending { it.rowWeight() } } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -118,6 +188,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" + private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName + private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } + private fun rowTotal(row: List) = row.sumOf { it.videosCount } + + companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -158,42 +234,59 @@ fun ChannelsScreen( val lastOpened = remember { mutableIntStateOf(-1) } var focusedRow by remember { mutableIntStateOf(0) } - Column(Modifier.fillMaxSize()) { + androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { if (viewModel.userChannels.isNotEmpty()) { - // row 1: the user's own channels; header = account icon + username - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.username ?: "You") + " — your channels", - ) - ChannelCardRow( + item { + RowHeader( + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", + ) + } + item { + ChannelRow( viewModel.userChannels, onOpenChannel, onOpenDrawer, firstFocused = true, - onLeftAtStart = onOpenDrawer, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, ) + } } - if (viewModel.localChannels.isNotEmpty()) { - RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer) - ChannelCardRow( - viewModel.localChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = false, - onLeftAtStart = {}, - ) + viewModel.localUserRows.forEach { group -> + item { + RowHeader( + avatar = group.avatar, + label = (group.label ?: "?") + " (" + group.videos + ")", + ) + } + item { + ChannelRow( + group.rows, + onOpenChannel, + {}, + firstFocused = false, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, + ) + } } - if (viewModel.followedChannels.isNotEmpty()) { - RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) - ChannelCardRow( - viewModel.followedChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = false, - onLeftAtStart = {}, - ) + viewModel.followedRows.forEach { group -> + item { + RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") + } + item { + ChannelRow( + group.rows, + onOpenChannel, + {}, + firstFocused = false, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, + ) + } } } } @@ -229,12 +322,13 @@ private fun absolute(pathOrUrl: String): String = /** Horizontal row of channel cards; eases newly focused cards into view. */ @Composable -private fun ChannelCardRow( +private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, firstFocused: Boolean, - onLeftAtStart: () -> Unit, + statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformProvider: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } @@ -268,6 +362,8 @@ private fun ChannelCardRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, + stats = statsProvider(channel), + platformName = platformProvider(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -275,12 +371,17 @@ private fun ChannelCardRow( } } -/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */ +/** + * Channel card: banner top (center-cropped), round avatar bottom-left, then + * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. + */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, + stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { var cardFocused by remember { mutableStateOf(false) } @@ -320,21 +421,23 @@ fun ChannelCard( val banner = channel.banners.maxByOrNull { it.width } if (banner != null) { AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(absolute(banner.fileUrl ?: banner.path)) .size(480) .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f), + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(absolute(avatar.fileUrl ?: avatar.path)) .size(80) .build(), @@ -343,7 +446,7 @@ fun ChannelCard( modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) - .size(36.dp) + .size(40.dp) .clip(CircleShape) .background(Color(0xFF333333)), ) @@ -357,19 +460,45 @@ fun ChannelCard( text = channel.displayName.ifBlank { channel.name }, style = MaterialTheme.typography.titleSmall, maxLines = 1, - minLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, color = if (cardFocused) Color(0xFF101010) else Color.White, ) Text( - text = channel.host + " · " + channel.videosCount + " videos · " + - channel.followersCount + " followers", + text = (channel.ownerAccount?.displayName ?: "") + + (if (platformName != null) " - " + platformName else ""), style = MaterialTheme.typography.bodySmall, color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically) { + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, + value = stats?.videos?.toString() ?: "-", + focused = cardFocused, + ) + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.Groups, + value = channel.followersCount.toString(), + focused = cardFocused, + ) + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, + value = stats?.plays?.toString() ?: "-", + focused = cardFocused, + ) + } } } } -} \ No newline at end of file +} + +@Composable +private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { + val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(value, style = MaterialTheme.typography.labelSmall, color = color) + } +} 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 index b2de95a..7e9927d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -88,20 +88,32 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. - BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { + // BACK order: player (own BackHandler) → drawer close → channel drilldown → + // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. + BackHandler(enabled = playingVideo == null && channelDetails == null) { val now = System.currentTimeMillis() - if (now - lastBackAt < 2000) { - (context as? android.app.Activity)?.finish() - } else { + if (drawerOpen) { + if (now - lastBackAt < 2500) { + (context as? android.app.Activity)?.finish() + return@BackHandler + } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() + } else { + lastBackAt = now + drawerOpen = true } } BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { channelDetails = null } BackHandler(enabled = drawerOpen && playingVideo == null) { + val now = System.currentTimeMillis() + if (now - lastBackAt < 2500) { + (context as? android.app.Activity)?.finish() + return@BackHandler + } + lastBackAt = now drawerOpen = false } @@ -167,8 +179,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenDrawer = { drawerOpen = true }, ) Destination.Subscriptions -> - VideoListScreen( - source = ListSource.Subscriptions, + SubscriptionRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, @@ -428,17 +439,6 @@ private fun MenuDrawerItem( } ) .focusable() - .onPreviewKeyEvent { ke -> - when (ke.key) { - androidx.compose.ui.input.key.Key.DirectionDown -> { - focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true - } - androidx.compose.ui.input.key.Key.DirectionUp -> { - focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true - } - else -> false - } - } .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt new file mode 100644 index 0000000..3a73e01 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -0,0 +1,168 @@ +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.VideoChannelSummary +import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.ImagePrefetcher +import net.jeena.jtubetv.ui.list.PagedListViewModel + +/** + * Subscriptions, SmartTube-style: the feed grouped by channel — one row per + * channel, newest-first within the row, row header = channel avatar + name. + * Card sizes match the Home rows exactly. + */ +class SubscriptionRowsViewModel( + private val repository: net.jeena.jtubetv.data.PeerTubeRepository = AppGraph.repository, +) : PagedListViewModel() { + + override fun onChunkLoaded(chunk: List) { + ImagePrefetcher.videoThumbnails(chunk) + } + + override suspend fun fetch(start: Long, count: Int): Result> = + repository.subscriptionVideos(start, count).map { it.data } +} + +private data class SubGroup( + val channel: VideoChannelSummary, + val startIndex: Int, + val videos: List, +) + +private fun buildGroups(items: List): List { + val groups = mutableListOf() + items.forEachIndexed { index, video -> + val last = groups.lastOrNull() + val same = last != null && + last.channel.name == video.channel.name && + last.channel.host == video.channel.host + if (same) { + groups[groups.lastIndex] = last!!.copy(videos = last.videos + video) + } else { + groups += SubGroup(video.channel, index, listOf(video)) + } + } + return groups +} + +@Composable +fun SubscriptionRowsScreen( + onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, + epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, + viewModel: SubscriptionRowsViewModel = viewModel( + key = "subsrows$epoch", + factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + SubscriptionRowsViewModel() as T + }, + ), +) { + val items = viewModel.items + LaunchedEffect(Unit) { if (items.isEmpty()) viewModel.loadMore(force = true) } + LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } + + if (items.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (viewModel.initialError != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Could not load subscriptions", color = MaterialTheme.colorScheme.error) + Text( + viewModel.initialError ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.Button(onClick = { viewModel.retryInitial() }) { + Text("Retry") + } + } + } else { + androidx.compose.material3.CircularProgressIndicator() + } + } + return + } + + val groups = remember(items) { buildGroups(items) } + + LazyColumn(Modifier.fillMaxSize()) { + groups.forEach { group -> + item(key = "subrowhead" + group.channel.name + "@" + group.channel.host + group.startIndex) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val avatar = group.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(avatar.fileUrl + ?: (AppGraph.tokenStore.baseUrl ?: "") + avatar.path) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF222222)), + ) + } + Text( + text = group.channel.displayName.ifBlank { group.channel.name }, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + ) + } + } + item(key = "subrow" + group.channel.name + "@" + group.channel.host + group.startIndex) { + FocusScrollRow( + videos = group.videos, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) { rowIndex, _ -> + if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { + viewModel.loadMore() + } + } + } + } + } +} \ No newline at end of file From d74a71758b9a448d31da0723e1dc4ecf888db866 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 05:42:02 +0000 Subject: [PATCH 32/53] channels: Per-user rows, empty-channels row, redesigned stats card, subs detail Channels now render as SmartTube rows and scroll vertically (LazyColumn - the unscrollable Column hid rows 2+). Local channels group one row per ownerAccount, sorted by total video count, with all zero-video channels merged into a final 'No videos yet' row. Followed-instance channels group per platform. The channel card matches the video-card language: banner center-cropped, round avatar bottom-left, channel name, 'owner display name - platform display name' (instance names via /config, local cached at login; no domain strings), and an icon stat line (videos | subscribers | plays); counts load per channel in the background since the listing endpoint doesn't carry them (verified on the live API). Subscriptions destination switches to per-channel rows, matching the Home card size. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 42 ++++ .../java/net/jeena/jtubetv/data/api/Dtos.kt | 26 +++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 3 + .../net/jeena/jtubetv/data/auth/TokenStore.kt | 5 + .../jtubetv/ui/screens/ChannelsScreen.kt | 217 ++++++++++++++---- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 36 +-- .../jtubetv/ui/screens/SubscriptionsScreen.kt | 168 ++++++++++++++ 7 files changed, 435 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 64d35bb..5079c0d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -2,7 +2,9 @@ package net.jeena.jtubetv.data import kotlinx.serialization.json.Json import net.jeena.jtubetv.data.api.HistoryElement +import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.OAuthClient +import net.jeena.jtubetv.data.api.ServerConfigDto import net.jeena.jtubetv.data.api.PeerTubeApi import net.jeena.jtubetv.data.api.TokenResponse import net.jeena.jtubetv.data.api.UserDto @@ -86,6 +88,9 @@ class PeerTubeRepository( .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) .build() .create(PeerTubeApi::class.java) + store.instanceName = try { + meApi.serverConfig().instance.name + } catch (_: Exception) { null } val me = meApi.me() store.accountName = me.username store.userDisplayName = me.displayName ?: me.username @@ -101,6 +106,43 @@ class PeerTubeRepository( suspend fun me(): Result = runCatching { network.api.me() } + suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } + + /** Display name of a (possibly foreign) instance, via that instance's /config. */ + suspend fun instanceName(rawHost: String): Result = runCatching { + val base = "https://" + rawHost + val api = unauthenticatedApiFor(base) + api.serverConfig().instance.name ?: rawHost + } + + /** + * videosCount + total plays for a channel (listing DTOs don't carry them). + * Sums views over up to [maxPages] video pages. + */ + suspend fun channelStats(handle: String, maxPages: Int = 4): Result = + runCatching { + var start = 0L + var videos = 0L + var plays = 0L + var total: Long? = null + repeat(maxPages) { + val page = network.api.channelVideos(handle, start, 25) + if (total == null) total = page.total + plays += page.data.sumOf { it.views } + start += page.data.size + videos = total ?: videos + } + net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + } + + private fun unauthenticatedApiFor(base: String): PeerTubeApi = + Retrofit.Builder() + .baseUrl(apiUrlOf(base) + "/") + .client(plainOkHttp()) + .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) + .build() + .create(PeerTubeApi::class.java) + // ---- video lists ------------------------------------------------------- suspend fun localVideos(start: Long, count: Int, search: String? = null): Result = 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 index 41cf173..b807ebc 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -60,6 +60,14 @@ data class VideoListResponse( val data: List, ) +@Serializable +data class ChannelOwnerAccount( + val name: String, + val host: String? = null, + @SerialName("displayName") val displayName: String? = null, + val avatars: List = emptyList(), +) + @Serializable data class VideoChannelDto( val name: String, @@ -70,6 +78,7 @@ data class VideoChannelDto( val banners: List = emptyList(), val videosCount: Long = 0, val followersCount: Long = 0, + val ownerAccount: ChannelOwnerAccount? = null, ) @Serializable @@ -134,6 +143,23 @@ data class VideoTokenFiles( val expires: String? = null, ) +@Serializable +data class ServerConfigDto( + val instance: InstanceInfoDto, +) + +@Serializable +data class InstanceInfoDto( + val name: String? = null, + val shortDescription: String? = null, +) + +/** Per-channel stats not available in the listing; fetched on demand. */ +data class ChannelStats( + val videos: Long, + val plays: Long, +) + @Serializable data class UserDto( val username: String, 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 index 76dd8e2..4c505ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -112,4 +112,7 @@ interface PeerTubeApi { @GET("users/me") suspend fun me(): UserDto + + @GET("config") + suspend fun serverConfig(): ServerConfigDto } \ 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 index 0b006ce..054da4d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -53,6 +53,11 @@ class TokenStore(context: Context) { get() = prefs.getString("userDisplayName", null) set(value) = prefs.edit().putString("userDisplayName", value).apply() + /** Human-friendly instance name (e.g. "JTube") for card subtitles. */ + var instanceName: String? + get() = prefs.getString("instanceName", null) + set(value) = prefs.edit().putString("instanceName", value).apply() + var userAvatarUrl: String? get() = prefs.getString("userAvatarUrl", null) set(value) = prefs.edit().putString("userAvatarUrl", value).apply() 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 index fa76b3a..707db29 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -4,6 +4,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -53,6 +57,17 @@ import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg +/** One row of channels belonging to one user (avatar + display name as header). */ +data class UserGroup( + val label: String?, + val avatar: Avatar?, + val videos: Long, + val rows: List, +) { + /** Sort weight: users with videos first (more videos first), empty channels last. */ + fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 +} + /** * Channels as SmartTube-style rows: * 1. "Your channels" — header with account avatar + username, then own channel cards. @@ -70,13 +85,28 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private set var localChannels by mutableStateOf>(emptyList()) private set - var followedChannels by mutableStateOf>(emptyList()) + + /** One row per (local) user, sorted by total video count; empties last. */ + var localUserRows by mutableStateOf>(emptyList()) private set + + /** Channels from instances this server follows, grouped by their instance. */ + var followedRows by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) private set var error by mutableStateOf(null) private set + /** "name@host" -> videos + plays; filled in the background. */ + var stats by mutableStateOf>(emptyMap()) + private set + + /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ + var platformNames by mutableStateOf>(emptyMap()) + private set + private val repo: PeerTubeRepository = AppGraph.repository fun load(force: Boolean = false) { @@ -85,13 +115,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { error = null viewModelScope.launch { try { - // row header (avatar + real username) user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() - // enumerate the whole channel list, split by host client-side val all = mutableListOf() var start = 0L var total: Long? = null @@ -102,14 +130,56 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 } + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') val ownNames = userChannels.map { c -> c.name }.toSet() val mine = all.filter { it.host == host && it.name in ownNames } val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } - followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName } + localChannels = rest.filter { it.host == host } + net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) + + // platform display names (local cached in prefs; foreign lazy) + platformNames = mapOf( + (host ?: "") to (store.instanceName ?: host ?: ""), + ) + val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") + for (fh in foreignHosts) { + val name = repo.instanceName(fh).getOrNull() ?: continue + platformNames = platformNames + (fh to name) + } + + // video + play counts per channel, one tiny listing call per channel + for (channel in (userChannels + localChannels)) { + val key = channel.name + "@" + channel.host + val cs = repo.channelStats(key).getOrNull() ?: continue + stats = stats + (key to cs) + } + // group LOCAL channels by owner (user): row per user, videos desc, + // channels with zero videos collected into a single last row. + val locals = rest.filter { it.host == host } + val withVideos = locals.filter { it.videosCount > 0 } + val empty = locals.filter { it.videosCount <= 0 } + val byOwner = withVideos.groupBy { ownerKey(it) } + localUserRows = + byOwner.values + .map { row -> UserGroup( + label = rowLabel(row), + avatar = rowAvatar(row), + videos = rowTotal(row), + rows = row.sortedByDescending { it.videosCount }, + ) } + .filter { it.label != null } + .sortedByDescending { it.rowWeight() } + if (empty.isNotEmpty()) { + localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) + } + followedRows = rest.filter { it.host != host } + .groupBy { it.host ?: "?" } + .values + .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } + .sortedByDescending { it.rowWeight() } } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -118,6 +188,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" + private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName + private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } + private fun rowTotal(row: List) = row.sumOf { it.videosCount } + + companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -158,42 +234,59 @@ fun ChannelsScreen( val lastOpened = remember { mutableIntStateOf(-1) } var focusedRow by remember { mutableIntStateOf(0) } - Column(Modifier.fillMaxSize()) { + androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { if (viewModel.userChannels.isNotEmpty()) { - // row 1: the user's own channels; header = account icon + username - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.username ?: "You") + " — your channels", - ) - ChannelCardRow( + item { + RowHeader( + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", + ) + } + item { + ChannelRow( viewModel.userChannels, onOpenChannel, onOpenDrawer, firstFocused = true, - onLeftAtStart = onOpenDrawer, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, ) + } } - if (viewModel.localChannels.isNotEmpty()) { - RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer) - ChannelCardRow( - viewModel.localChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = false, - onLeftAtStart = {}, - ) + viewModel.localUserRows.forEach { group -> + item { + RowHeader( + avatar = group.avatar, + label = (group.label ?: "?") + " (" + group.videos + ")", + ) + } + item { + ChannelRow( + group.rows, + onOpenChannel, + {}, + firstFocused = false, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, + ) + } } - if (viewModel.followedChannels.isNotEmpty()) { - RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) - ChannelCardRow( - viewModel.followedChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = false, - onLeftAtStart = {}, - ) + viewModel.followedRows.forEach { group -> + item { + RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") + } + item { + ChannelRow( + group.rows, + onOpenChannel, + {}, + firstFocused = false, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, + ) + } } } } @@ -229,12 +322,13 @@ private fun absolute(pathOrUrl: String): String = /** Horizontal row of channel cards; eases newly focused cards into view. */ @Composable -private fun ChannelCardRow( +private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit, firstFocused: Boolean, - onLeftAtStart: () -> Unit, + statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformProvider: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } @@ -268,6 +362,8 @@ private fun ChannelCardRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, + stats = statsProvider(channel), + platformName = platformProvider(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -275,12 +371,17 @@ private fun ChannelCardRow( } } -/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */ +/** + * Channel card: banner top (center-cropped), round avatar bottom-left, then + * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. + */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, + stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { var cardFocused by remember { mutableStateOf(false) } @@ -320,21 +421,23 @@ fun ChannelCard( val banner = channel.banners.maxByOrNull { it.width } if (banner != null) { AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(absolute(banner.fileUrl ?: banner.path)) .size(480) .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f), + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { AsyncImage( - model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) .data(absolute(avatar.fileUrl ?: avatar.path)) .size(80) .build(), @@ -343,7 +446,7 @@ fun ChannelCard( modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) - .size(36.dp) + .size(40.dp) .clip(CircleShape) .background(Color(0xFF333333)), ) @@ -357,19 +460,45 @@ fun ChannelCard( text = channel.displayName.ifBlank { channel.name }, style = MaterialTheme.typography.titleSmall, maxLines = 1, - minLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, color = if (cardFocused) Color(0xFF101010) else Color.White, ) Text( - text = channel.host + " · " + channel.videosCount + " videos · " + - channel.followersCount + " followers", + text = (channel.ownerAccount?.displayName ?: "") + + (if (platformName != null) " - " + platformName else ""), style = MaterialTheme.typography.bodySmall, color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically) { + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, + value = stats?.videos?.toString() ?: "-", + focused = cardFocused, + ) + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.Groups, + value = channel.followersCount.toString(), + focused = cardFocused, + ) + StatWithIcon( + icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, + value = stats?.plays?.toString() ?: "-", + focused = cardFocused, + ) + } } } } -} \ No newline at end of file +} + +@Composable +private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { + val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(value, style = MaterialTheme.typography.labelSmall, color = color) + } +} 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 index b2de95a..7e9927d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -88,20 +88,32 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. - BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { + // BACK order: player (own BackHandler) → drawer close → channel drilldown → + // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. + BackHandler(enabled = playingVideo == null && channelDetails == null) { val now = System.currentTimeMillis() - if (now - lastBackAt < 2000) { - (context as? android.app.Activity)?.finish() - } else { + if (drawerOpen) { + if (now - lastBackAt < 2500) { + (context as? android.app.Activity)?.finish() + return@BackHandler + } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() + } else { + lastBackAt = now + drawerOpen = true } } BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { channelDetails = null } BackHandler(enabled = drawerOpen && playingVideo == null) { + val now = System.currentTimeMillis() + if (now - lastBackAt < 2500) { + (context as? android.app.Activity)?.finish() + return@BackHandler + } + lastBackAt = now drawerOpen = false } @@ -167,8 +179,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi onOpenDrawer = { drawerOpen = true }, ) Destination.Subscriptions -> - VideoListScreen( - source = ListSource.Subscriptions, + SubscriptionRowsScreen( onOpenVideo = { playingVideo = it.shortUUID }, restoreToken = restoreToken, epoch = epoch, @@ -428,17 +439,6 @@ private fun MenuDrawerItem( } ) .focusable() - .onPreviewKeyEvent { ke -> - when (ke.key) { - androidx.compose.ui.input.key.Key.DirectionDown -> { - focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true - } - androidx.compose.ui.input.key.Key.DirectionUp -> { - focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true - } - else -> false - } - } .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt new file mode 100644 index 0000000..3a73e01 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -0,0 +1,168 @@ +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import net.jeena.jtubetv.AppGraph +import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.VideoChannelSummary +import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.ImagePrefetcher +import net.jeena.jtubetv.ui.list.PagedListViewModel + +/** + * Subscriptions, SmartTube-style: the feed grouped by channel — one row per + * channel, newest-first within the row, row header = channel avatar + name. + * Card sizes match the Home rows exactly. + */ +class SubscriptionRowsViewModel( + private val repository: net.jeena.jtubetv.data.PeerTubeRepository = AppGraph.repository, +) : PagedListViewModel() { + + override fun onChunkLoaded(chunk: List) { + ImagePrefetcher.videoThumbnails(chunk) + } + + override suspend fun fetch(start: Long, count: Int): Result> = + repository.subscriptionVideos(start, count).map { it.data } +} + +private data class SubGroup( + val channel: VideoChannelSummary, + val startIndex: Int, + val videos: List, +) + +private fun buildGroups(items: List): List { + val groups = mutableListOf() + items.forEachIndexed { index, video -> + val last = groups.lastOrNull() + val same = last != null && + last.channel.name == video.channel.name && + last.channel.host == video.channel.host + if (same) { + groups[groups.lastIndex] = last!!.copy(videos = last.videos + video) + } else { + groups += SubGroup(video.channel, index, listOf(video)) + } + } + return groups +} + +@Composable +fun SubscriptionRowsScreen( + onOpenVideo: (VideoDto) -> Unit, + restoreToken: Int = 0, + epoch: Int = 0, + onOpenDrawer: () -> Unit = {}, + viewModel: SubscriptionRowsViewModel = viewModel( + key = "subsrows$epoch", + factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + SubscriptionRowsViewModel() as T + }, + ), +) { + val items = viewModel.items + LaunchedEffect(Unit) { if (items.isEmpty()) viewModel.loadMore(force = true) } + LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() } + + if (items.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (viewModel.initialError != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Could not load subscriptions", color = MaterialTheme.colorScheme.error) + Text( + viewModel.initialError ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + androidx.compose.material3.Button(onClick = { viewModel.retryInitial() }) { + Text("Retry") + } + } + } else { + androidx.compose.material3.CircularProgressIndicator() + } + } + return + } + + val groups = remember(items) { buildGroups(items) } + + LazyColumn(Modifier.fillMaxSize()) { + groups.forEach { group -> + item(key = "subrowhead" + group.channel.name + "@" + group.channel.host + group.startIndex) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val avatar = group.channel.avatars.maxByOrNull { it.width } + if (avatar != null) { + AsyncImage( + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(avatar.fileUrl + ?: (AppGraph.tokenStore.baseUrl ?: "") + avatar.path) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF222222)), + ) + } + Text( + text = group.channel.displayName.ifBlank { group.channel.name }, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + ) + } + } + item(key = "subrow" + group.channel.name + "@" + group.channel.host + group.startIndex) { + FocusScrollRow( + videos = group.videos, + progressOf = { null }, + onOpenVideo = onOpenVideo, + onLeftAtStart = onOpenDrawer, + ) { rowIndex, _ -> + if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { + viewModel.loadMore() + } + } + } + } + } +} \ No newline at end of file From 8ae74058add842c5d300852f9c53bf4acdf86544 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:13:59 +0000 Subject: [PATCH 33/53] ui: All list screens use home-style 4-per-row card size Grid screens (My videos, History, Search, channel pages) switch from 3-column to adaptive columns sized exactly like the Home rows' cards, with the compact single-line title, so every screen reads the same. --- .../net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 1cfe009..0113c92 100644 --- 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 @@ -33,6 +33,7 @@ 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.screens.rowCardWidth import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.VideoListItem import net.jeena.jtubetv.ui.list.VideoListViewModel @@ -120,7 +121,8 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(3), + // Same card size as the Home rows: ~4 fit per row (user request). + columns = GridCells.Adaptive(rowCardWidth() - 12.dp), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { @@ -136,6 +138,7 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier .focusRequester(fr), From dd7b7ebd21b13858be8449f71419097c5c4759ab Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:13:59 +0000 Subject: [PATCH 34/53] ui: All list screens use home-style 4-per-row card size Grid screens (My videos, History, Search, channel pages) switch from 3-column to adaptive columns sized exactly like the Home rows' cards, with the compact single-line title, so every screen reads the same. --- .../net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 1cfe009..0113c92 100644 --- 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 @@ -33,6 +33,7 @@ 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.screens.rowCardWidth import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.VideoListItem import net.jeena.jtubetv.ui.list.VideoListViewModel @@ -120,7 +121,8 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(3), + // Same card size as the Home rows: ~4 fit per row (user request). + columns = GridCells.Adaptive(rowCardWidth() - 12.dp), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { @@ -136,6 +138,7 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier .focusRequester(fr), From 83c2b1b286fae7047b6593f5d91e32af100016f0 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:45:56 +0000 Subject: [PATCH 35/53] ui: Fix drawer focus-eater + BACK/RIGHT/LEFT menu affordances The drawer's tap-to-close scrim was focusable: LEFT/RIGHT from a drawer item moved focus onto the invisible fullscreen scrim, and OK mysteriously dismissed - the drawer felt un-navigable. Scrim is no longer focusable; RIGHT (from the drawer) closes it and BACK closes it; closing restores focus to the search button. LEFT on any row's leftmost card opens the drawer (used to be only the first row). Banner prefetch keys now match the render keys (480x270) so channel banners stop reloading on focus movement. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 334 ++++++++---------- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 21 +- 2 files changed, 173 insertions(+), 182 deletions(-) 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 index 707db29..9fa1e26 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -1,14 +1,9 @@ package net.jeena.jtubetv.ui.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -33,65 +35,61 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.foundation.BorderStroke import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto +import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -/** One row of channels belonging to one user (avatar + display name as header). */ -data class UserGroup( - val label: String?, - val avatar: Avatar?, - val videos: Long, - val rows: List, -) { - /** Sort weight: users with videos first (more videos first), empty channels last. */ - fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 -} - /** - * Channels as SmartTube-style rows: - * 1. "Your channels" — header with account avatar + username, then own channel cards. - * 2. "Local channels" — the instance's own channels. The public API has NO - * local-only filter (verified in v8.3.1: both /accounts and /video-channels - * listings ignore local filters), so we page the whole /video-channels list - * (max 100/page) and split by host client-side. - * 3. "Followed instances" — channels of instances this server follows. + * Channels, SmartTube-style: one row per LOCAL user. + * + * - Row 1: the logged-in user ("'s Channels"), then the other local + * users sorted by videos / subscribers / plays (desc). + * - Each row: that user's channels, same ordering; channels with no videos simply + * stay inside their user's row. + * - Followed-instance channels at the end, grouped per platform. + * + * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings + * have no isLocal filter and the listing carries no videosCount/views, so we page + * the full listing (max 100/page) and fetch per-channel stats (videos + sum of + * video views = plays) with small throttled calls. */ +data class UserGroup( + val ownerName: String, + val label: String, + val avatar: Avatar?, + val channels: List, +) + class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) private set - var localChannels by mutableStateOf>(emptyList()) - private set - /** One row per (local) user, sorted by total video count; empties last. */ - var localUserRows by mutableStateOf>(emptyList()) - private set - - /** Channels from instances this server follows, grouped by their instance. */ - var followedRows by mutableStateOf>(emptyList()) + var userRows by mutableStateOf>(emptyList()) private set var loading by mutableStateOf(true) @@ -99,8 +97,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> videos + plays; filled in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> stats; filled progressively in the background. */ + var stats by mutableStateOf>(emptyMap()) private set /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ @@ -110,7 +108,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private val repo: PeerTubeRepository = AppGraph.repository fun load(force: Boolean = false) { - if (!force && userChannels.isNotEmpty()) return + if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { @@ -120,11 +118,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() var start = 0L var total: Long? = null + var retries = 0 while (total == null || start < total) { - val page = repo.channelList(start, 100).getOrNull() ?: break + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue + } + retries = 0 if (total == null) total = page.second val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } @@ -132,54 +139,50 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val ownNames = userChannels.map { c -> c.name }.toSet() - val mine = all.filter { it.host == host && it.name in ownNames } - val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } - userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host } + val locals = all.filter { it.host == host } - net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) - - // platform display names (local cached in prefs; foreign lazy) - platformNames = mapOf( - (host ?: "") to (store.instanceName ?: host ?: ""), - ) - val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") - for (fh in foreignHosts) { - val name = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to name) + // platform display names: local one cached at login; foreign ones lazy + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) + for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { + val n = repo.instanceName(fh).getOrNull() ?: continue + platformNames = platformNames + (fh to n) } - // video + play counts per channel, one tiny listing call per channel - for (channel in (userChannels + localChannels)) { + // stats for every local channel: throttle below the server rate limit + val statsMap = HashMap() + for (channel in locals) { val key = channel.name + "@" + channel.host val cs = repo.channelStats(key).getOrNull() ?: continue - stats = stats + (key to cs) + statsMap[key] = cs + stats = statsMap.toMap() + delay(250) } - // group LOCAL channels by owner (user): row per user, videos desc, - // channels with zero videos collected into a single last row. - val locals = rest.filter { it.host == host } - val withVideos = locals.filter { it.videosCount > 0 } - val empty = locals.filter { it.videosCount <= 0 } - val byOwner = withVideos.groupBy { ownerKey(it) } - localUserRows = - byOwner.values - .map { row -> UserGroup( - label = rowLabel(row), - avatar = rowAvatar(row), - videos = rowTotal(row), - rows = row.sortedByDescending { it.videosCount }, - ) } - .filter { it.label != null } - .sortedByDescending { it.rowWeight() } - if (empty.isNotEmpty()) { - localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) + + // --- rows per user, sorted by videos / subscribers / plays ----------- + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sortedChannels = row.sortedWith( + compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + .thenByDescending { it.followersCount } + .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sortedChannels, + ) } - followedRows = rest.filter { it.host != host } - .groupBy { it.host ?: "?" } - .values - .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } - .sortedByDescending { it.rowWeight() } + // logged-in user first, then the others by videos / subs / plays + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + + ImagePrefetcher.channelArt(all + userChannels) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -188,11 +191,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" - private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName - private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } - private fun rowTotal(row: List) = row.sumOf { it.videosCount } - + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -212,11 +211,12 @@ fun ChannelsScreen( ) { LaunchedEffect(epoch) { viewModel.load(force = true) } + when { - viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + viewModel.loading && viewModel.userRows.isEmpty() -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { androidx.compose.material3.CircularProgressIndicator() } - viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + viewModel.error != null && viewModel.userRows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Could not load channels", color = MaterialTheme.colorScheme.error) @@ -231,60 +231,22 @@ fun ChannelsScreen( } } else -> { - val lastOpened = remember { mutableIntStateOf(-1) } - var focusedRow by remember { mutableIntStateOf(0) } - - androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { - if (viewModel.userChannels.isNotEmpty()) { - item { - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", - ) - } - item { - ChannelRow( - viewModel.userChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = true, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.localUserRows.forEach { group -> - item { + LazyColumn(Modifier.fillMaxSize()) { + viewModel.userRows.forEach { group -> + item(key = "hdr-" + group.ownerName) { RowHeader( avatar = group.avatar, - label = (group.label ?: "?") + " (" + group.videos + ")", + label = group.label, ) } - item { + item(key = "row-" + group.ownerName) { ChannelRow( - group.rows, + group.channels, onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.followedRows.forEach { group -> - item { - RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") - } - item { - ChannelRow( - group.rows, - onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, + onOpenDrawer, + firstFocused = group == viewModel.userRows.firstOrNull(), + stats = { viewModel.stats[it.name + "@" + it.host] }, + platformName = { viewModel.platformNames[it.host ?: ""] }, ) } } @@ -293,9 +255,9 @@ fun ChannelsScreen( } } -/** Row header: user icon + username, or plain label. */ +/** Row header: user icon + "'s Channels". */ @Composable -private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { +private fun RowHeader(avatar: Avatar?, label: String) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), @@ -303,7 +265,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = ) { if (avatar != null) { AsyncImage( - model = absolute(avatar.fileUrl ?: avatar.path), + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -316,24 +283,25 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = } } -private fun absolute(pathOrUrl: String): String = - if (pathOrUrl.startsWith("http")) pathOrUrl - else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl +private fun absoluteAvatar(avatar: Avatar): String = + (avatar.fileUrl ?: avatar.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } -/** Horizontal row of channel cards; eases newly focused cards into view. */ +/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ @Composable private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, - onOpenDrawer: () -> Unit, + onOpenDrawer: () -> Unit = {}, firstFocused: Boolean, - statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, - platformProvider: (VideoChannelDto) -> String?, + stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformName: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } - var focusedIndex by remember { mutableIntStateOf(-1) } - var clickedIndex by remember { mutableIntStateOf(-1) } + var focusedIndex by remember { mutableStateOf(-1) } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { @@ -351,7 +319,7 @@ private fun ChannelRow( } } - androidx.compose.foundation.lazy.LazyRow( + LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -362,8 +330,8 @@ private fun ChannelRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, - stats = statsProvider(channel), - platformName = platformProvider(channel), + stats = stats(channel), + platformName = platformName(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -371,16 +339,16 @@ private fun ChannelRow( } } -/** - * Channel card: banner top (center-cropped), round avatar bottom-left, then - * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. +/** + * Channel card: banner (center-cropped), round avatar bottom-left, channel name, + * "owner - platform" subtitle, then icon stats: videos | subscribers | plays. */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, - stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + stats: ChannelStats? = null, platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { @@ -424,8 +392,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(banner.fileUrl ?: banner.path)) - .size(480) + .data(absoluteBanner(banner)) + .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, @@ -438,8 +406,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(avatar.fileUrl ?: avatar.path)) - .size(80) + .data(absoluteAvatar(avatar)) + .size(80, 80) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -471,34 +439,42 @@ fun ChannelCard( maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, - value = stats?.videos?.toString() ?: "-", - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.Groups, - value = channel.followersCount.toString(), - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, - value = stats?.plays?.toString() ?: "-", - focused = cardFocused, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused) + StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused) + StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused) } } } } } +private fun absoluteBanner(banner: Avatar): String = + (banner.fileUrl ?: banner.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + @Composable -private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { +private fun StatWithIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + focused: Boolean, +) { val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { - androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + androidx.tv.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(13.dp), + ) Text(value, style = MaterialTheme.typography.labelSmall, color = color) } -} +} \ 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 index 7e9927d..5cdefeb 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -81,6 +81,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } + val searchFocus = remember { FocusRequester() } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -115,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } lastBackAt = now drawerOpen = false + runCatching { searchFocus.requestFocus() } } Box( @@ -142,6 +144,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi TopBar( onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, + searchFocus = searchFocus, ) Box( modifier = Modifier @@ -236,6 +239,7 @@ private fun Destination.icon(): ImageVector = private fun TopBar( onSearchClick: () -> Unit, onAccountClick: () -> Unit, + searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( @@ -249,6 +253,7 @@ private fun TopBar( icon = Icons.Filled.Search, desc = "Search", onClick = onSearchClick, + focusRequester = searchFocus, ) // "Who is logged in" — account button: avatar circle + display name (SmartTube). val accountFocused = remember { mutableStateOf(false) } @@ -317,6 +322,7 @@ private fun RoundTopButton( icon: ImageVector, desc: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Box( @@ -330,6 +336,7 @@ private fun RoundTopButton( 3.dp, Color.White, CircleShape ) else Modifier ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() .clickable(onClick = onClick), contentAlignment = Alignment.Center, @@ -364,7 +371,16 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp), + .padding(vertical = 24.dp) + .onPreviewKeyEvent { ke -> + // RIGHT from the drawer closes it; focus returns to the top bar. + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT + ) { + onDismiss() + true + } else false + }, ) { Text( text = "JTube TV", @@ -401,12 +417,11 @@ private fun MenuDrawer( modifier = Modifier, ) } - // tap/click outside closes; focus LEFT from the drawer also closes + // tap/click outside closes the drawer Box( modifier = Modifier .weight(1f) .fillMaxSize() - .focusable() .pointerInput(Unit) { detectTapGestures { onDismiss() } }, ) } From 5ebf06398d15d6ffb78872f99914fc8bee42b1ad Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 06:45:56 +0000 Subject: [PATCH 36/53] ui: Fix drawer focus-eater + BACK/RIGHT/LEFT menu affordances The drawer's tap-to-close scrim was focusable: LEFT/RIGHT from a drawer item moved focus onto the invisible fullscreen scrim, and OK mysteriously dismissed - the drawer felt un-navigable. Scrim is no longer focusable; RIGHT (from the drawer) closes it and BACK closes it; closing restores focus to the search button. LEFT on any row's leftmost card opens the drawer (used to be only the first row). Banner prefetch keys now match the render keys (480x270) so channel banners stop reloading on focus movement. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 334 ++++++++---------- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 21 +- 2 files changed, 173 insertions(+), 182 deletions(-) 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 index 707db29..9fa1e26 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -1,14 +1,9 @@ package net.jeena.jtubetv.ui.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -33,65 +35,61 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.foundation.BorderStroke import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.api.Avatar +import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto +import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg -/** One row of channels belonging to one user (avatar + display name as header). */ -data class UserGroup( - val label: String?, - val avatar: Avatar?, - val videos: Long, - val rows: List, -) { - /** Sort weight: users with videos first (more videos first), empty channels last. */ - fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 -} - /** - * Channels as SmartTube-style rows: - * 1. "Your channels" — header with account avatar + username, then own channel cards. - * 2. "Local channels" — the instance's own channels. The public API has NO - * local-only filter (verified in v8.3.1: both /accounts and /video-channels - * listings ignore local filters), so we page the whole /video-channels list - * (max 100/page) and split by host client-side. - * 3. "Followed instances" — channels of instances this server follows. + * Channels, SmartTube-style: one row per LOCAL user. + * + * - Row 1: the logged-in user ("'s Channels"), then the other local + * users sorted by videos / subscribers / plays (desc). + * - Each row: that user's channels, same ordering; channels with no videos simply + * stay inside their user's row. + * - Followed-instance channels at the end, grouped per platform. + * + * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings + * have no isLocal filter and the listing carries no videosCount/views, so we page + * the full listing (max 100/page) and fetch per-channel stats (videos + sum of + * video views = plays) with small throttled calls. */ +data class UserGroup( + val ownerName: String, + val label: String, + val avatar: Avatar?, + val channels: List, +) + class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) private set - var localChannels by mutableStateOf>(emptyList()) - private set - /** One row per (local) user, sorted by total video count; empties last. */ - var localUserRows by mutableStateOf>(emptyList()) - private set - - /** Channels from instances this server follows, grouped by their instance. */ - var followedRows by mutableStateOf>(emptyList()) + var userRows by mutableStateOf>(emptyList()) private set var loading by mutableStateOf(true) @@ -99,8 +97,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> videos + plays; filled in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> stats; filled progressively in the background. */ + var stats by mutableStateOf>(emptyMap()) private set /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ @@ -110,7 +108,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { private val repo: PeerTubeRepository = AppGraph.repository fun load(force: Boolean = false) { - if (!force && userChannels.isNotEmpty()) return + if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { @@ -120,11 +118,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() var start = 0L var total: Long? = null + var retries = 0 while (total == null || start < total) { - val page = repo.channelList(start, 100).getOrNull() ?: break + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue + } + retries = 0 if (total == null) total = page.second val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } @@ -132,54 +139,50 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val ownNames = userChannels.map { c -> c.name }.toSet() - val mine = all.filter { it.host == host && it.name in ownNames } - val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } - userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host } + val locals = all.filter { it.host == host } - net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) - - // platform display names (local cached in prefs; foreign lazy) - platformNames = mapOf( - (host ?: "") to (store.instanceName ?: host ?: ""), - ) - val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") - for (fh in foreignHosts) { - val name = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to name) + // platform display names: local one cached at login; foreign ones lazy + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) + for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { + val n = repo.instanceName(fh).getOrNull() ?: continue + platformNames = platformNames + (fh to n) } - // video + play counts per channel, one tiny listing call per channel - for (channel in (userChannels + localChannels)) { + // stats for every local channel: throttle below the server rate limit + val statsMap = HashMap() + for (channel in locals) { val key = channel.name + "@" + channel.host val cs = repo.channelStats(key).getOrNull() ?: continue - stats = stats + (key to cs) + statsMap[key] = cs + stats = statsMap.toMap() + delay(250) } - // group LOCAL channels by owner (user): row per user, videos desc, - // channels with zero videos collected into a single last row. - val locals = rest.filter { it.host == host } - val withVideos = locals.filter { it.videosCount > 0 } - val empty = locals.filter { it.videosCount <= 0 } - val byOwner = withVideos.groupBy { ownerKey(it) } - localUserRows = - byOwner.values - .map { row -> UserGroup( - label = rowLabel(row), - avatar = rowAvatar(row), - videos = rowTotal(row), - rows = row.sortedByDescending { it.videosCount }, - ) } - .filter { it.label != null } - .sortedByDescending { it.rowWeight() } - if (empty.isNotEmpty()) { - localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) + + // --- rows per user, sorted by videos / subscribers / plays ----------- + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sortedChannels = row.sortedWith( + compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + .thenByDescending { it.followersCount } + .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sortedChannels, + ) } - followedRows = rest.filter { it.host != host } - .groupBy { it.host ?: "?" } - .values - .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } - .sortedByDescending { it.rowWeight() } + // logged-in user first, then the others by videos / subs / plays + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + + ImagePrefetcher.channelArt(all + userChannels) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -188,11 +191,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" - private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName - private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } - private fun rowTotal(row: List) = row.sumOf { it.videosCount } - + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -212,11 +211,12 @@ fun ChannelsScreen( ) { LaunchedEffect(epoch) { viewModel.load(force = true) } + when { - viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + viewModel.loading && viewModel.userRows.isEmpty() -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { androidx.compose.material3.CircularProgressIndicator() } - viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + viewModel.error != null && viewModel.userRows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Could not load channels", color = MaterialTheme.colorScheme.error) @@ -231,60 +231,22 @@ fun ChannelsScreen( } } else -> { - val lastOpened = remember { mutableIntStateOf(-1) } - var focusedRow by remember { mutableIntStateOf(0) } - - androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { - if (viewModel.userChannels.isNotEmpty()) { - item { - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", - ) - } - item { - ChannelRow( - viewModel.userChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = true, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.localUserRows.forEach { group -> - item { + LazyColumn(Modifier.fillMaxSize()) { + viewModel.userRows.forEach { group -> + item(key = "hdr-" + group.ownerName) { RowHeader( avatar = group.avatar, - label = (group.label ?: "?") + " (" + group.videos + ")", + label = group.label, ) } - item { + item(key = "row-" + group.ownerName) { ChannelRow( - group.rows, + group.channels, onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.followedRows.forEach { group -> - item { - RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") - } - item { - ChannelRow( - group.rows, - onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, + onOpenDrawer, + firstFocused = group == viewModel.userRows.firstOrNull(), + stats = { viewModel.stats[it.name + "@" + it.host] }, + platformName = { viewModel.platformNames[it.host ?: ""] }, ) } } @@ -293,9 +255,9 @@ fun ChannelsScreen( } } -/** Row header: user icon + username, or plain label. */ +/** Row header: user icon + "'s Channels". */ @Composable -private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { +private fun RowHeader(avatar: Avatar?, label: String) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), @@ -303,7 +265,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = ) { if (avatar != null) { AsyncImage( - model = absolute(avatar.fileUrl ?: avatar.path), + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -316,24 +283,25 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = } } -private fun absolute(pathOrUrl: String): String = - if (pathOrUrl.startsWith("http")) pathOrUrl - else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl +private fun absoluteAvatar(avatar: Avatar): String = + (avatar.fileUrl ?: avatar.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } -/** Horizontal row of channel cards; eases newly focused cards into view. */ +/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ @Composable private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, - onOpenDrawer: () -> Unit, + onOpenDrawer: () -> Unit = {}, firstFocused: Boolean, - statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, - platformProvider: (VideoChannelDto) -> String?, + stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformName: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } - var focusedIndex by remember { mutableIntStateOf(-1) } - var clickedIndex by remember { mutableIntStateOf(-1) } + var focusedIndex by remember { mutableStateOf(-1) } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { @@ -351,7 +319,7 @@ private fun ChannelRow( } } - androidx.compose.foundation.lazy.LazyRow( + LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -362,8 +330,8 @@ private fun ChannelRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, - stats = statsProvider(channel), - platformName = platformProvider(channel), + stats = stats(channel), + platformName = platformName(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -371,16 +339,16 @@ private fun ChannelRow( } } -/** - * Channel card: banner top (center-cropped), round avatar bottom-left, then - * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. +/** + * Channel card: banner (center-cropped), round avatar bottom-left, channel name, + * "owner - platform" subtitle, then icon stats: videos | subscribers | plays. */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, - stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + stats: ChannelStats? = null, platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { @@ -424,8 +392,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(banner.fileUrl ?: banner.path)) - .size(480) + .data(absoluteBanner(banner)) + .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, @@ -438,8 +406,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(avatar.fileUrl ?: avatar.path)) - .size(80) + .data(absoluteAvatar(avatar)) + .size(80, 80) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -471,34 +439,42 @@ fun ChannelCard( maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, - value = stats?.videos?.toString() ?: "-", - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.Groups, - value = channel.followersCount.toString(), - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, - value = stats?.plays?.toString() ?: "-", - focused = cardFocused, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused) + StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused) + StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused) } } } } } +private fun absoluteBanner(banner: Avatar): String = + (banner.fileUrl ?: banner.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + @Composable -private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { +private fun StatWithIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + focused: Boolean, +) { val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { - androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + androidx.tv.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(13.dp), + ) Text(value, style = MaterialTheme.typography.labelSmall, color = color) } -} +} \ 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 index 7e9927d..5cdefeb 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -81,6 +81,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } + val searchFocus = remember { FocusRequester() } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -115,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } lastBackAt = now drawerOpen = false + runCatching { searchFocus.requestFocus() } } Box( @@ -142,6 +144,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi TopBar( onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, + searchFocus = searchFocus, ) Box( modifier = Modifier @@ -236,6 +239,7 @@ private fun Destination.icon(): ImageVector = private fun TopBar( onSearchClick: () -> Unit, onAccountClick: () -> Unit, + searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( @@ -249,6 +253,7 @@ private fun TopBar( icon = Icons.Filled.Search, desc = "Search", onClick = onSearchClick, + focusRequester = searchFocus, ) // "Who is logged in" — account button: avatar circle + display name (SmartTube). val accountFocused = remember { mutableStateOf(false) } @@ -317,6 +322,7 @@ private fun RoundTopButton( icon: ImageVector, desc: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Box( @@ -330,6 +336,7 @@ private fun RoundTopButton( 3.dp, Color.White, CircleShape ) else Modifier ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() .clickable(onClick = onClick), contentAlignment = Alignment.Center, @@ -364,7 +371,16 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp), + .padding(vertical = 24.dp) + .onPreviewKeyEvent { ke -> + // RIGHT from the drawer closes it; focus returns to the top bar. + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT + ) { + onDismiss() + true + } else false + }, ) { Text( text = "JTube TV", @@ -401,12 +417,11 @@ private fun MenuDrawer( modifier = Modifier, ) } - // tap/click outside closes; focus LEFT from the drawer also closes + // tap/click outside closes the drawer Box( modifier = Modifier .weight(1f) .fillMaxSize() - .focusable() .pointerInput(Unit) { detectTapGestures { onDismiss() } }, ) } From a85ae3c124278b0634938834e04bd76509432dc9 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:01:27 +0000 Subject: [PATCH 37/53] channels: Render rows early (pages only), guard rebuild, Long-safe sort keys The channels screen waited for every listing page AND all per-channel stats before showing anything - a 40s+ blank spinner. Rows now render per page (unsorted), re-sorting as stat batches arrive. rebuildRows is guarded; the earlier ClassCastException (Integer into a Long comparator) surfaced via the sort call and can no longer abort the app. All sort keys go through Long-typed helpers. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) 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 index 9fa1e26..5decf11 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale @@ -105,18 +106,30 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var platformNames by mutableStateOf>(emptyMap()) private set + private var allChannels: List = emptyList() + + /** Channels from followed instances, grouped per platform. */ + var followedRows by mutableStateOf>(emptyList()) + private set + private val repo: PeerTubeRepository = AppGraph.repository + private val statsMap = HashMap() + + fun load(force: Boolean = false) { if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { + // The user's own row renders FIRST (fast: one call), the rest streams in. user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() @@ -136,53 +149,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + allChannels = all + rebuildRows(host) } - - val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val locals = all.filter { it.host == host } - - // platform display names: local one cached at login; foreign ones lazy - platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { - val n = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to n) - } - - // stats for every local channel: throttle below the server rate limit - val statsMap = HashMap() - for (channel in locals) { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() ?: continue - statsMap[key] = cs - stats = statsMap.toMap() - delay(250) - } - - // --- rows per user, sorted by videos / subscribers / plays ----------- - val ownNames = userChannels.map { c -> c.name }.toSet() - val byOwner = locals.groupBy { ownerKey(it) } - val groups = byOwner.values.map { row -> - val sortedChannels = row.sortedWith( - compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } - .thenByDescending { it.followersCount } - .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, - ) - val own = row.firstOrNull { it.name in ownNames } - UserGroup( - ownerName = ownerKey(row.first()), - label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", - avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, - channels = sortedChannels, - ) - } - // logged-in user first, then the others by videos / subs / plays - userRows = groups.sortedWith( - compareByDescending { g -> g.channels.any { it.name in ownNames } } - .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } - .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, - ) - ImagePrefetcher.channelArt(all + userChannels) + + // stats (videos + plays) for every local channel: throttled parallel-ish + val locals = all.filter { it.host == host } + channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -191,6 +165,73 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Fetch stats for [channels], re-sorting rows as data lands. */ + private fun channelStatsLoop(channels: List, host: String?) { + viewModelScope.launch { + for (channel in channels) { + val key = channel.name + "@" + channel.host + if (statsMap.containsKey(key)) continue + val cs = repo.channelStats(key).getOrNull() ?: continue + statsMap[key] = cs + stats = statsMap.toMap() + rebuildRows(host) + delay(120) + } + } + } + + private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L + private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L + + /** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */ + private fun rebuildRows(host: String?) { + runCatching { rebuildRowsInner(host) }.onFailure { + android.util.Log.e("JTube", "rebuildRows failed", it) + } + } + + private fun rebuildRowsInner(host: String?) { + val locals = allChannels.filter { it.host == host } + val foreign = allChannels.filter { it.host != host } + followedRows = foreign + .groupBy { it.host ?: "?" } + .values + .map { row -> + UserGroup( + ownerName = row.firstOrNull()?.host ?: "?", + label = (platformNames[row.firstOrNull()?.host] + ?: row.firstOrNull()?.host ?: "?") + " (followed)", + avatar = null, + channels = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount }, + ), + ) + } + .sortedByDescending { g -> g.channels.sumOf { it.followersCount } } + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sorted = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sorted, + ) + } + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { @@ -358,6 +399,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && From be221e44b19d5cf42805988bb7df34feb198db67 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:01:27 +0000 Subject: [PATCH 38/53] channels: Render rows early (pages only), guard rebuild, Long-safe sort keys The channels screen waited for every listing page AND all per-channel stats before showing anything - a 40s+ blank spinner. Rows now render per page (unsorted), re-sorting as stat batches arrive. rebuildRows is guarded; the earlier ClassCastException (Integer into a Long comparator) surfaced via the sort call and can no longer abort the app. All sort keys go through Long-typed helpers. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) 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 index 9fa1e26..5decf11 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale @@ -105,18 +106,30 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var platformNames by mutableStateOf>(emptyMap()) private set + private var allChannels: List = emptyList() + + /** Channels from followed instances, grouped per platform. */ + var followedRows by mutableStateOf>(emptyList()) + private set + private val repo: PeerTubeRepository = AppGraph.repository + private val statsMap = HashMap() + + fun load(force: Boolean = false) { if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { + // The user's own row renders FIRST (fast: one call), the rest streams in. user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() @@ -136,53 +149,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + allChannels = all + rebuildRows(host) } - - val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val locals = all.filter { it.host == host } - - // platform display names: local one cached at login; foreign ones lazy - platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) - for (fh in locals.mapNotNull { it.ownerAccount?.host }.toSet()) { - val n = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to n) - } - - // stats for every local channel: throttle below the server rate limit - val statsMap = HashMap() - for (channel in locals) { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() ?: continue - statsMap[key] = cs - stats = statsMap.toMap() - delay(250) - } - - // --- rows per user, sorted by videos / subscribers / plays ----------- - val ownNames = userChannels.map { c -> c.name }.toSet() - val byOwner = locals.groupBy { ownerKey(it) } - val groups = byOwner.values.map { row -> - val sortedChannels = row.sortedWith( - compareByDescending { statsMap[it.name + "@" + it.host]?.videos ?: 0 } - .thenByDescending { it.followersCount } - .thenByDescending { statsMap[it.name + "@" + it.host]?.plays ?: 0 }, - ) - val own = row.firstOrNull { it.name in ownNames } - UserGroup( - ownerName = ownerKey(row.first()), - label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", - avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, - channels = sortedChannels, - ) - } - // logged-in user first, then the others by videos / subs / plays - userRows = groups.sortedWith( - compareByDescending { g -> g.channels.any { it.name in ownNames } } - .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } - .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, - ) - ImagePrefetcher.channelArt(all + userChannels) + + // stats (videos + plays) for every local channel: throttled parallel-ish + val locals = all.filter { it.host == host } + channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -191,6 +165,73 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } + /** Fetch stats for [channels], re-sorting rows as data lands. */ + private fun channelStatsLoop(channels: List, host: String?) { + viewModelScope.launch { + for (channel in channels) { + val key = channel.name + "@" + channel.host + if (statsMap.containsKey(key)) continue + val cs = repo.channelStats(key).getOrNull() ?: continue + statsMap[key] = cs + stats = statsMap.toMap() + rebuildRows(host) + delay(120) + } + } + } + + private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L + private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L + + /** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */ + private fun rebuildRows(host: String?) { + runCatching { rebuildRowsInner(host) }.onFailure { + android.util.Log.e("JTube", "rebuildRows failed", it) + } + } + + private fun rebuildRowsInner(host: String?) { + val locals = allChannels.filter { it.host == host } + val foreign = allChannels.filter { it.host != host } + followedRows = foreign + .groupBy { it.host ?: "?" } + .values + .map { row -> + UserGroup( + ownerName = row.firstOrNull()?.host ?: "?", + label = (platformNames[row.firstOrNull()?.host] + ?: row.firstOrNull()?.host ?: "?") + " (followed)", + avatar = null, + channels = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount }, + ), + ) + } + .sortedByDescending { g -> g.channels.sumOf { it.followersCount } } + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sorted = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sorted, + ) + } + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + } + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { @@ -358,6 +399,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && From f3ee72f43e0b9346b7b718b9da32c85f595f267e Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:28:19 +0000 Subject: [PATCH 39/53] ui: BACK = up the hierarchy (channel->channels->home->exit guard) The BACK-opens-drawer behavior made navigation loops with the drawer focus restore; replaced with the TV-standard: BACK leaves the player, closes the drawer, leaves a channel drilldown, or goes Home; from Home it is the double-press exit guard. Drawer opens via the account button / LEFT on a leftmost card. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 20 +++++++++- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 39 ++++++++----------- 2 files changed, 36 insertions(+), 23 deletions(-) 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 index 5decf11..0ee6ab5 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -49,6 +49,7 @@ import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import android.util.Log import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -149,6 +150,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + android.util.Log.w( + "JTube", + "channels page " + (start / 100) + ": total=" + total + " all=" + all.size + + " locals=" + all.count { it.host == host }, + ) allChannels = all rebuildRows(host) } @@ -156,6 +162,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { // stats (videos + plays) for every local channel: throttled parallel-ish val locals = all.filter { it.host == host } + Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + + " userChannels=" + userChannels.size) channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" @@ -171,7 +179,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { for (channel in channels) { val key = channel.name + "@" + channel.host if (statsMap.containsKey(key)) continue - val cs = repo.channelStats(key).getOrNull() ?: continue + val cs = repo.channelStats(key).getOrNull() + if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } + android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) statsMap[key] = cs stats = statsMap.toMap() rebuildRows(host) @@ -230,6 +240,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) + Log.w( + "JTube", + "order now: " + userRows.take(6).joinToString(" | ") { g -> + g.ownerName + "(" + g.channels.size + "ch, " + + g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + "v" + + (if (g.channels.any { it.name in ownNames }) ", OWN" else "") + ")" + }, + ) } private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" 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 index 5cdefeb..a9d790b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -76,7 +76,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle - var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + // NOT saveable: launching the app should never resume a half-watched player session. + var playingVideo by remember { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current @@ -89,35 +90,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer close → channel drilldown → - // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. - BackHandler(enabled = playingVideo == null && channelDetails == null) { - val now = System.currentTimeMillis() - if (drawerOpen) { + // BACK order (TV standard): player (own handler) → drawer close → channel + // drilldown → non-Home destination → Home; from Home: double-press exits. + BackHandler(enabled = drawerOpen && playingVideo == null) { + drawerOpen = false + runCatching { searchFocus.requestFocus() } + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { + channelDetails = null + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) { + if (destination != Destination.Home) { + channelDetails = null + destination = Destination.Home + } else { + val now = System.currentTimeMillis() if (now - lastBackAt < 2500) { (context as? android.app.Activity)?.finish() return@BackHandler } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() - } else { - lastBackAt = now - drawerOpen = true } } - BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { - channelDetails = null - } - BackHandler(enabled = drawerOpen && playingVideo == null) { - val now = System.currentTimeMillis() - if (now - lastBackAt < 2500) { - (context as? android.app.Activity)?.finish() - return@BackHandler - } - lastBackAt = now - drawerOpen = false - runCatching { searchFocus.requestFocus() } - } Box( modifier = Modifier From c11638f3757e817fa2f8d0681ef7e2efa2f8199e Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:28:19 +0000 Subject: [PATCH 40/53] ui: BACK = up the hierarchy (channel->channels->home->exit guard) The BACK-opens-drawer behavior made navigation loops with the drawer focus restore; replaced with the TV-standard: BACK leaves the player, closes the drawer, leaves a channel drilldown, or goes Home; from Home it is the double-press exit guard. Drawer opens via the account button / LEFT on a leftmost card. --- .../jtubetv/ui/screens/ChannelsScreen.kt | 20 +++++++++- .../jeena/jtubetv/ui/screens/HomeScreen.kt | 39 ++++++++----------- 2 files changed, 36 insertions(+), 23 deletions(-) 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 index 5decf11..0ee6ab5 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -49,6 +49,7 @@ import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import android.util.Log import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph @@ -149,6 +150,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + android.util.Log.w( + "JTube", + "channels page " + (start / 100) + ": total=" + total + " all=" + all.size + + " locals=" + all.count { it.host == host }, + ) allChannels = all rebuildRows(host) } @@ -156,6 +162,8 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { // stats (videos + plays) for every local channel: throttled parallel-ish val locals = all.filter { it.host == host } + Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + + " userChannels=" + userChannels.size) channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" @@ -171,7 +179,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { for (channel in channels) { val key = channel.name + "@" + channel.host if (statsMap.containsKey(key)) continue - val cs = repo.channelStats(key).getOrNull() ?: continue + val cs = repo.channelStats(key).getOrNull() + if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } + android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) statsMap[key] = cs stats = statsMap.toMap() rebuildRows(host) @@ -230,6 +240,14 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) + Log.w( + "JTube", + "order now: " + userRows.take(6).joinToString(" | ") { g -> + g.ownerName + "(" + g.channels.size + "ch, " + + g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + "v" + + (if (g.channels.any { it.name in ownNames }) ", OWN" else "") + ")" + }, + ) } private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" 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 index 5cdefeb..a9d790b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -76,7 +76,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle - var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + // NOT saveable: launching the app should never resume a half-watched player session. + var playingVideo by remember { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current @@ -89,35 +90,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer close → channel drilldown → - // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. - BackHandler(enabled = playingVideo == null && channelDetails == null) { - val now = System.currentTimeMillis() - if (drawerOpen) { + // BACK order (TV standard): player (own handler) → drawer close → channel + // drilldown → non-Home destination → Home; from Home: double-press exits. + BackHandler(enabled = drawerOpen && playingVideo == null) { + drawerOpen = false + runCatching { searchFocus.requestFocus() } + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { + channelDetails = null + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) { + if (destination != Destination.Home) { + channelDetails = null + destination = Destination.Home + } else { + val now = System.currentTimeMillis() if (now - lastBackAt < 2500) { (context as? android.app.Activity)?.finish() return@BackHandler } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() - } else { - lastBackAt = now - drawerOpen = true } } - BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { - channelDetails = null - } - BackHandler(enabled = drawerOpen && playingVideo == null) { - val now = System.currentTimeMillis() - if (now - lastBackAt < 2500) { - (context as? android.app.Activity)?.finish() - return@BackHandler - } - lastBackAt = now - drawerOpen = false - runCatching { searchFocus.requestFocus() } - } Box( modifier = Modifier From 906b2391c9361e6d40a6af4ab1a896c47315a22d Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:31:18 +0000 Subject: [PATCH 41/53] ui: Explicit UP from first content row to the top bar Focus search silently failed to move from the first content card up to the top bar (why the menu felt unreachable); the first row's cards now have a wired UP target (the search button) in rows and grids. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 ++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 5 +++++ .../jtubetv/ui/screens/SubscriptionsScreen.kt | 2 ++ .../jtubetv/ui/screens/list/VideoCard.kt | 19 +++++++++++++------ .../ui/screens/list/VideoListScreen.kt | 2 ++ 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 69dafd1..97aa411 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -153,6 +153,7 @@ fun HomeRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } @@ -188,6 +189,7 @@ fun HomeRowsScreen( onLeftAtStart = onOpenDrawer, clickedIndex = continueRestore, restoreToken = restoreToken, + upToTopBar = upToTopBar, ) } } @@ -257,6 +259,7 @@ fun FocusScrollRow( onLeftAtStart: () -> Unit, clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click restoreToken: Int = 0, // bumped when playback ends + upToTopBar: (() -> Unit)? = null, // first content row only afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -315,6 +318,7 @@ fun FocusScrollRow( compact = true, progress = progressOf(video.shortUUID), onLeftAtStart = if (index == 0) onLeftAtStart else null, + onUpToTopBar = upToTopBar, onFocused = { focusedIndex = index }, ) afterItem(index, video) 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 index a9d790b..5a66d6c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -154,6 +154,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -162,6 +163,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -175,6 +177,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> SubscriptionRowsScreen( @@ -182,6 +185,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -190,6 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt index 3a73e01..d6e7eff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -82,6 +82,7 @@ fun SubscriptionRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: SubscriptionRowsViewModel = viewModel( key = "subsrows$epoch", factory = object : ViewModelProvider.Factory { @@ -157,6 +158,7 @@ fun SubscriptionRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() }, ) { rowIndex, _ -> if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { viewModel.loadMore() 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 index 4adffc4..3d4a240 100644 --- 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 @@ -59,6 +59,8 @@ fun VideoCard( compact: Boolean = false, /** LEFT (only on the row's first card) can open the menu drawer. */ onLeftAtStart: (() -> Unit)? = null, + /** Explicit UP-target for the first content row (focus search misses the top bar). */ + onUpToTopBar: (() -> Unit)? = null, /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ onFocused: (() -> Unit)? = null, ) { @@ -72,12 +74,17 @@ fun VideoCard( if (it.isFocused) onFocused?.invoke() } .onPreviewKeyEvent { ke -> - if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && - ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && - onLeftAtStart != null - ) { - onLeftAtStart() - true + val direction = ke.key + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) { + when { + direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> { + onLeftAtStart(); true + } + direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> { + onUpToTopBar(); true + } + else -> false + } } else false }, colors = CardDefaults.colors( 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 index 0113c92..02e43b3 100644 --- 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 @@ -45,6 +45,7 @@ fun VideoListScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -140,6 +141,7 @@ fun VideoListScreen( showPrivacy = source is ListSource.MyVideos, compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, + onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), ) From f9b0dd0dbcc31baa10e712f681b5dfd8e0bf912b Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:31:18 +0000 Subject: [PATCH 42/53] ui: Explicit UP from first content row to the top bar Focus search silently failed to move from the first content card up to the top bar (why the menu felt unreachable); the first row's cards now have a wired UP target (the search button) in rows and grids. --- .../net/jeena/jtubetv/ui/screens/HomeRows.kt | 4 ++++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 5 +++++ .../jtubetv/ui/screens/SubscriptionsScreen.kt | 2 ++ .../jtubetv/ui/screens/list/VideoCard.kt | 19 +++++++++++++------ .../ui/screens/list/VideoListScreen.kt | 2 ++ 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index 69dafd1..97aa411 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -153,6 +153,7 @@ fun HomeRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } @@ -188,6 +189,7 @@ fun HomeRowsScreen( onLeftAtStart = onOpenDrawer, clickedIndex = continueRestore, restoreToken = restoreToken, + upToTopBar = upToTopBar, ) } } @@ -257,6 +259,7 @@ fun FocusScrollRow( onLeftAtStart: () -> Unit, clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click restoreToken: Int = 0, // bumped when playback ends + upToTopBar: (() -> Unit)? = null, // first content row only afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -315,6 +318,7 @@ fun FocusScrollRow( compact = true, progress = progressOf(video.shortUUID), onLeftAtStart = if (index == 0) onLeftAtStart else null, + onUpToTopBar = upToTopBar, onFocused = { focusedIndex = index }, ) afterItem(index, video) 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 index a9d790b..5a66d6c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -154,6 +154,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -162,6 +163,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -175,6 +177,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> SubscriptionRowsScreen( @@ -182,6 +185,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -190,6 +194,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt index 3a73e01..d6e7eff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -82,6 +82,7 @@ fun SubscriptionRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: SubscriptionRowsViewModel = viewModel( key = "subsrows$epoch", factory = object : ViewModelProvider.Factory { @@ -157,6 +158,7 @@ fun SubscriptionRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() }, ) { rowIndex, _ -> if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { viewModel.loadMore() 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 index 4adffc4..3d4a240 100644 --- 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 @@ -59,6 +59,8 @@ fun VideoCard( compact: Boolean = false, /** LEFT (only on the row's first card) can open the menu drawer. */ onLeftAtStart: (() -> Unit)? = null, + /** Explicit UP-target for the first content row (focus search misses the top bar). */ + onUpToTopBar: (() -> Unit)? = null, /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ onFocused: (() -> Unit)? = null, ) { @@ -72,12 +74,17 @@ fun VideoCard( if (it.isFocused) onFocused?.invoke() } .onPreviewKeyEvent { ke -> - if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && - ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && - onLeftAtStart != null - ) { - onLeftAtStart() - true + val direction = ke.key + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) { + when { + direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> { + onLeftAtStart(); true + } + direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> { + onUpToTopBar(); true + } + else -> false + } } else false }, colors = CardDefaults.colors( 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 index 0113c92..02e43b3 100644 --- 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 @@ -45,6 +45,7 @@ fun VideoListScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -140,6 +141,7 @@ fun VideoListScreen( showPrivacy = source is ListSource.MyVideos, compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, + onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), ) From 6ef9180fa1af2d5ded1a2e3170b19d52d2a15337 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:44:35 +0000 Subject: [PATCH 43/53] ui: CENTER activates everything (clickable lacks DPAD_CENTER) Compose clickable handles Enter/Space only; whether the TV's OK (DPAD_CENTER) triggers click depends on the device's input stack - worked on the API-36 host emulator, dead on the API-34 ATD and possibly some TVs. A preview-tunnelled center handler now activates drawer items, top-bar buttons, video and channel cards, and the login/search buttons deterministically, exactly once (preview consumption prevents the platform path from double-firing). --- .../jtubetv/ui/screens/ChannelsScreen.kt | 2 ++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 4 ++++ .../jeena/jtubetv/ui/screens/SetupScreen.kt | 19 +++++++++++---- .../jtubetv/ui/screens/list/FocusKeys.kt | 24 +++++++++++++++++++ .../jtubetv/ui/screens/list/VideoCard.kt | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt 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 index 0ee6ab5..8bcf936 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -62,6 +62,7 @@ import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Channels, SmartTube-style: one row per LOCAL user. @@ -417,6 +418,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && 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 index 5a66d6c..b031715 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -55,6 +55,7 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable enum class Destination(val title: String) { Home("Home"), @@ -266,6 +267,7 @@ private fun TopBar( else Modifier ) .focusable() + .dpadCenterActivatable(onAccountClick) .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), @@ -338,6 +340,7 @@ private fun RoundTopButton( ) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -454,6 +457,7 @@ private fun MenuDrawerItem( } ) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, 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 index 3f4fc88..a41f989 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Feature 0: ask for instance URL + credentials once. The password is used only for @@ -164,9 +165,6 @@ fun SetupScreen( Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error) } Button( - // Deliberately always focusable: on TV a permanently disabled button - // is unreachable and leaves the user stuck without feedback (UX review). - // Validation happens on press with an inline message instead. onClick = { when { url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> @@ -178,7 +176,20 @@ fun SetupScreen( } }, enabled = !busy.value, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .dpadCenterActivatable( + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + ), ) { if (busy.value) { CircularProgressIndicator(Modifier.width(20.dp)) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt new file mode 100644 index 0000000..b7adec0 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -0,0 +1,24 @@ +package net.jeena.jtubetv.ui.screens.list + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.onPreviewKeyEvent + +/** + * Compose `clickable` handles Enter/Space but NOT the TV's DPAD_CENTER; whether + * CENTER works depends on each device's input translation (works on some emulators, + * not on others — see the API-34 ATD case). This modifier consumes CENTER *first* + * (preview/tunneling) so the click fires exactly once, on every device. + */ +fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { + onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER + ) { + onClick() + true + } else false + } +} \ 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 index 3d4a240..5d4e1a9 100644 --- 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 @@ -69,6 +69,7 @@ fun VideoCard( onClick = onClick, modifier = modifier .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused if (it.isFocused) onFocused?.invoke() From 7894847144f0c937c7d0960f2b385cc435f5a228 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:44:35 +0000 Subject: [PATCH 44/53] ui: CENTER activates everything (clickable lacks DPAD_CENTER) Compose clickable handles Enter/Space only; whether the TV's OK (DPAD_CENTER) triggers click depends on the device's input stack - worked on the API-36 host emulator, dead on the API-34 ATD and possibly some TVs. A preview-tunnelled center handler now activates drawer items, top-bar buttons, video and channel cards, and the login/search buttons deterministically, exactly once (preview consumption prevents the platform path from double-firing). --- .../jtubetv/ui/screens/ChannelsScreen.kt | 2 ++ .../jeena/jtubetv/ui/screens/HomeScreen.kt | 4 ++++ .../jeena/jtubetv/ui/screens/SetupScreen.kt | 19 +++++++++++---- .../jtubetv/ui/screens/list/FocusKeys.kt | 24 +++++++++++++++++++ .../jtubetv/ui/screens/list/VideoCard.kt | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt 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 index 0ee6ab5..8bcf936 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -62,6 +62,7 @@ import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Channels, SmartTube-style: one row per LOCAL user. @@ -417,6 +418,7 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && 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 index 5a66d6c..b031715 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -55,6 +55,7 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable enum class Destination(val title: String) { Home("Home"), @@ -266,6 +267,7 @@ private fun TopBar( else Modifier ) .focusable() + .dpadCenterActivatable(onAccountClick) .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), @@ -338,6 +340,7 @@ private fun RoundTopButton( ) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -454,6 +457,7 @@ private fun MenuDrawerItem( } ) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, 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 index 3f4fc88..a41f989 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Feature 0: ask for instance URL + credentials once. The password is used only for @@ -164,9 +165,6 @@ fun SetupScreen( Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error) } Button( - // Deliberately always focusable: on TV a permanently disabled button - // is unreachable and leaves the user stuck without feedback (UX review). - // Validation happens on press with an inline message instead. onClick = { when { url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> @@ -178,7 +176,20 @@ fun SetupScreen( } }, enabled = !busy.value, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .dpadCenterActivatable( + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + ), ) { if (busy.value) { CircularProgressIndicator(Modifier.width(20.dp)) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt new file mode 100644 index 0000000..b7adec0 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -0,0 +1,24 @@ +package net.jeena.jtubetv.ui.screens.list + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.onPreviewKeyEvent + +/** + * Compose `clickable` handles Enter/Space but NOT the TV's DPAD_CENTER; whether + * CENTER works depends on each device's input translation (works on some emulators, + * not on others — see the API-34 ATD case). This modifier consumes CENTER *first* + * (preview/tunneling) so the click fires exactly once, on every device. + */ +fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { + onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER + ) { + onClick() + true + } else false + } +} \ 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 index 3d4a240..5d4e1a9 100644 --- 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 @@ -69,6 +69,7 @@ fun VideoCard( onClick = onClick, modifier = modifier .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused if (it.isFocused) onFocused?.invoke() From 013d343ae9539910be8123047e811cecea92ad35 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:47:06 +0000 Subject: [PATCH 45/53] =?UTF-8?q?scripts:=20emulator-seed.sh=20=E2=80=94?= =?UTF-8?q?=20seed=20a=20fresh=20emulated=20device=20with=20the=20test=20a?= =?UTF-8?q?ccount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/emulator-seed.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 scripts/emulator-seed.sh diff --git a/scripts/emulator-seed.sh b/scripts/emulator-seed.sh new file mode 100755 index 0000000..7a4bd91 --- /dev/null +++ b/scripts/emulator-seed.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# emulator-seed.sh — (container-side) log the freshly booted emulated device into the +# JTube test account via the debug-only seed intent. Usage: +# ./emulator-seed.sh emulator-5568 [serial] +set -euo pipefail +SERIAL="${1:-emulator-5568}" +ADB="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb" +[ -x "$ADB" ] && ADB="$ADB" || ADB="$(which adb)" + +# credentials from the encrypted store +eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + +# kill + cold start so onCreate (the debug seeder) actually runs +"$ADB" -s "$SERIAL" shell am force-stop net.jeena.jtubetv 2>/dev/null || true +"$ADB" -s "$SERIAL" install -r "$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" 2>&1 | tail -1 + +"$ADB" -s "$SERIAL" shell am start -a android.intent.action.MAIN \ + -n net.jeena.jtubetv/.MainActivity \ + --es jtube_url "$JTUBE_URL" \ + --es jtube_user "$JTUBE_USER" \ + --es jtube_pass "$JTUBE_PASS" + +sleep 12 +"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 +"$ADB" -s "$SERIAL" shell cat /sdcard/ui.xml | python3 -c " +import sys, re +xml = sys.stdin.read() +texts = [m.group(1) for m in re.finditer(r'text=\"([^\"]{2,40})\"', xml)] +foc = re.search(r']*focused=\"true\"[^>]*>', xml) +print('logged in as:', texts[0] if texts else '?') +print('rows/screens:', texts[1:8])" \ No newline at end of file From 1a8d8fce5b69c84bd393978dd1cad2f8d15be711 Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 11 Oct 2026 07:47:06 +0000 Subject: [PATCH 46/53] =?UTF-8?q?scripts:=20emulator-seed.sh=20=E2=80=94?= =?UTF-8?q?=20seed=20a=20fresh=20emulated=20device=20with=20tvbot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/emulator-seed.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100755 scripts/emulator-seed.sh diff --git a/scripts/emulator-seed.sh b/scripts/emulator-seed.sh new file mode 100755 index 0000000..7a4bd91 --- /dev/null +++ b/scripts/emulator-seed.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# emulator-seed.sh — (container-side) log the freshly booted emulated device into the +# JTube test account via the debug-only seed intent. Usage: +# ./emulator-seed.sh emulator-5568 [serial] +set -euo pipefail +SERIAL="${1:-emulator-5568}" +ADB="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb" +[ -x "$ADB" ] && ADB="$ADB" || ADB="$(which adb)" + +# credentials from the encrypted store +eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + +# kill + cold start so onCreate (the debug seeder) actually runs +"$ADB" -s "$SERIAL" shell am force-stop net.jeena.jtubetv 2>/dev/null || true +"$ADB" -s "$SERIAL" install -r "$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" 2>&1 | tail -1 + +"$ADB" -s "$SERIAL" shell am start -a android.intent.action.MAIN \ + -n net.jeena.jtubetv/.MainActivity \ + --es jtube_url "$JTUBE_URL" \ + --es jtube_user "$JTUBE_USER" \ + --es jtube_pass "$JTUBE_PASS" + +sleep 12 +"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 +"$ADB" -s "$SERIAL" shell cat /sdcard/ui.xml | python3 -c " +import sys, re +xml = sys.stdin.read() +texts = [m.group(1) for m in re.finditer(r'text=\"([^\"]{2,40})\"', xml)] +foc = re.search(r']*focused=\"true\"[^>]*>', xml) +print('logged in as:', texts[0] if texts else '?') +print('rows/screens:', texts[1:8])" \ No newline at end of file From 2c2d2dd25a205a43f7613239bbacb5041764a3a3 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 47/53] channels: Show a banner and harden D-pad activation Channel pages now load the channel record itself and use the newest video's thumbnail as a banner fallback, so channels never render without a header image. Focus cards in re-sorting rows only hand out initial focus once per screen visit so streaming stats can't steal focus mid-navigation. D-pad activation now also accepts ENTER/NUMPAD_ENTER/SPACE, matching how the Compose platform fires clickable cards; consuming in the preview phase keeps OK from double-activating. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 13 ++++++++-- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 2 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 5 ++++ .../jtubetv/ui/list/VideoListViewModel.kt | 20 ++++++++++++++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 24 ++++++++++++++----- .../jtubetv/ui/screens/list/FocusKeys.kt | 15 ++++++++---- 6 files changed, 66 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 5079c0d..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,14 +125,20 @@ class PeerTubeRepository( var videos = 0L var plays = 0L var total: Long? = null + var latestThumb: String? = null repeat(maxPages) { val page = network.api.channelVideos(handle, start, 25) - if (total == null) total = page.total + if (total == null) { + total = page.total + latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path -> + if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/') + } + } plays += page.data.sumOf { it.views } start += page.data.size videos = total ?: videos } - net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb) } private fun unauthenticatedApiFor(base: String): PeerTubeApi = @@ -178,6 +184,9 @@ class PeerTubeRepository( response.data to response.total } + suspend fun videoChannel(handle: String): Result = + runCatching { network.api.videoChannel(handle) } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) 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 index b807ebc..91d3777 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -158,6 +158,8 @@ data class InstanceInfoDto( data class ChannelStats( val videos: Long, val plays: Long, + /** Thumbnail of the newest video — banner fallback for channels without one. */ + val latestThumbUrl: String? = null, ) @Serializable 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 index 4c505ce..1c5940a 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -83,6 +83,11 @@ interface PeerTubeApi { @Path("account") account: String, ): VideoChannelListResponse + @GET("video-channels/{handle}") + suspend fun videoChannel( + @Path("handle") handle: String, + ): VideoChannelDto + @GET("video-channels/{handle}/videos") suspend fun channelVideos( @Path("handle") handle: String, 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 index 33bf4aa..a723e8e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -1,8 +1,13 @@ 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.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository @@ -30,6 +35,12 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + /** For channel pages: the channel itself (banner + display name) as page header. */ + var channelHeader by androidx.compose.runtime.mutableStateOf( + null, + ) + private set + override fun onChunkLoaded(chunk: List) { ImagePrefetcher.videoThumbnails( chunk.mapNotNull { @@ -60,6 +71,15 @@ class VideoListViewModel( } } + fun loadChannelHeader(repository: PeerTubeRepository) { + val s = source + if (s is ListSource.Channel) { + viewModelScope.launch { + channelHeader = repository.videoChannel(s.handle).getOrNull() + } + } + } + fun triggerSearch(raw: String) { query.value = raw activeSearch = raw.trim().ifBlank { null } 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 index 8bcf936..a97a119 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -291,6 +291,10 @@ fun ChannelsScreen( } } else -> { + // hand the initial focus to the first row exactly ONCE per screen visit: + // as stats stream in the rows re-sort, and re-focusing their first card + // would steal focus mid-navigation (user: "up jumps to the first row"). + val focusHandedOut = remember { mutableStateOf(false) } LazyColumn(Modifier.fillMaxSize()) { viewModel.userRows.forEach { group -> item(key = "hdr-" + group.ownerName) { @@ -304,7 +308,9 @@ fun ChannelsScreen( group.channels, onOpenChannel, onOpenDrawer, - firstFocused = group == viewModel.userRows.firstOrNull(), + focused = !focusHandedOut.value && + group == viewModel.userRows.firstOrNull(), + onFocused = { focusHandedOut.value = true }, stats = { viewModel.stats[it.name + "@" + it.host] }, platformName = { viewModel.platformNames[it.host ?: ""] }, ) @@ -355,7 +361,8 @@ private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit = {}, - firstFocused: Boolean, + focused: Boolean, + onFocused: () -> Unit = {}, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformName: (VideoChannelDto) -> String?, ) { @@ -373,9 +380,11 @@ private fun ChannelRow( if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } - if (firstFocused) { + if (focused) { LaunchedEffect(channels.isNotEmpty()) { - if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + if (channels.isNotEmpty()) { + if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() + } } } @@ -448,13 +457,16 @@ fun ChannelCard( .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { + // banner, or the newest video's thumbnail for bannerless channels val banner = channel.banners.maxByOrNull { it.width } - if (banner != null) { + val coverUrl = banner?.let { absoluteBanner(it) } + ?: stats?.latestThumbUrl + if (coverUrl != null) { AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absoluteBanner(banner)) + .data(coverUrl) .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt index b7adec0..7088eb4 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -14,11 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent */ fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { onPreviewKeyEvent { ke -> - if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && - ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER - ) { - onClick() - true + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + val code = ke.nativeKeyEvent.keyCode + val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + code == android.view.KeyEvent.KEYCODE_ENTER || + code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER || + code == android.view.KeyEvent.KEYCODE_SPACE + if (activates) { + onClick() + true // consumed in preview -> the platform/card path never double-fires + } else false } else false } } \ No newline at end of file From 12462e6f90da7449a7be78de3aca0f4d47e2e490 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 48/53] channels: Show a banner and harden D-pad activation Channel pages now load the channel record itself and use the newest video's thumbnail as a banner fallback, so channels never render without a header image. Focus cards in re-sorting rows only hand out initial focus once per screen visit so streaming stats can't steal focus mid-navigation. D-pad activation now also accepts ENTER/NUMPAD_ENTER/SPACE, matching how the Compose platform fires clickable cards; consuming in the preview phase keeps OK from double-activating. --- .../jeena/jtubetv/data/PeerTubeRepository.kt | 13 ++++++++-- .../java/net/jeena/jtubetv/data/api/Dtos.kt | 2 ++ .../net/jeena/jtubetv/data/api/PeerTubeApi.kt | 5 ++++ .../jtubetv/ui/list/VideoListViewModel.kt | 20 ++++++++++++++++ .../jtubetv/ui/screens/ChannelsScreen.kt | 24 ++++++++++++++----- .../jtubetv/ui/screens/list/FocusKeys.kt | 15 ++++++++---- 6 files changed, 66 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 5079c0d..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,14 +125,20 @@ class PeerTubeRepository( var videos = 0L var plays = 0L var total: Long? = null + var latestThumb: String? = null repeat(maxPages) { val page = network.api.channelVideos(handle, start, 25) - if (total == null) total = page.total + if (total == null) { + total = page.total + latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path -> + if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/') + } + } plays += page.data.sumOf { it.views } start += page.data.size videos = total ?: videos } - net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb) } private fun unauthenticatedApiFor(base: String): PeerTubeApi = @@ -178,6 +184,9 @@ class PeerTubeRepository( response.data to response.total } + suspend fun videoChannel(handle: String): Result = + runCatching { network.api.videoChannel(handle) } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) 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 index b807ebc..91d3777 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -158,6 +158,8 @@ data class InstanceInfoDto( data class ChannelStats( val videos: Long, val plays: Long, + /** Thumbnail of the newest video — banner fallback for channels without one. */ + val latestThumbUrl: String? = null, ) @Serializable 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 index 4c505ce..1c5940a 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -83,6 +83,11 @@ interface PeerTubeApi { @Path("account") account: String, ): VideoChannelListResponse + @GET("video-channels/{handle}") + suspend fun videoChannel( + @Path("handle") handle: String, + ): VideoChannelDto + @GET("video-channels/{handle}/videos") suspend fun channelVideos( @Path("handle") handle: String, 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 index 33bf4aa..a723e8e 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -1,8 +1,13 @@ 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.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository @@ -30,6 +35,12 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + /** For channel pages: the channel itself (banner + display name) as page header. */ + var channelHeader by androidx.compose.runtime.mutableStateOf( + null, + ) + private set + override fun onChunkLoaded(chunk: List) { ImagePrefetcher.videoThumbnails( chunk.mapNotNull { @@ -60,6 +71,15 @@ class VideoListViewModel( } } + fun loadChannelHeader(repository: PeerTubeRepository) { + val s = source + if (s is ListSource.Channel) { + viewModelScope.launch { + channelHeader = repository.videoChannel(s.handle).getOrNull() + } + } + } + fun triggerSearch(raw: String) { query.value = raw activeSearch = raw.trim().ifBlank { null } 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 index 8bcf936..a97a119 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -291,6 +291,10 @@ fun ChannelsScreen( } } else -> { + // hand the initial focus to the first row exactly ONCE per screen visit: + // as stats stream in the rows re-sort, and re-focusing their first card + // would steal focus mid-navigation (user: "up jumps to the first row"). + val focusHandedOut = remember { mutableStateOf(false) } LazyColumn(Modifier.fillMaxSize()) { viewModel.userRows.forEach { group -> item(key = "hdr-" + group.ownerName) { @@ -304,7 +308,9 @@ fun ChannelsScreen( group.channels, onOpenChannel, onOpenDrawer, - firstFocused = group == viewModel.userRows.firstOrNull(), + focused = !focusHandedOut.value && + group == viewModel.userRows.firstOrNull(), + onFocused = { focusHandedOut.value = true }, stats = { viewModel.stats[it.name + "@" + it.host] }, platformName = { viewModel.platformNames[it.host ?: ""] }, ) @@ -355,7 +361,8 @@ private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, onOpenDrawer: () -> Unit = {}, - firstFocused: Boolean, + focused: Boolean, + onFocused: () -> Unit = {}, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformName: (VideoChannelDto) -> String?, ) { @@ -373,9 +380,11 @@ private fun ChannelRow( if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } - if (firstFocused) { + if (focused) { LaunchedEffect(channels.isNotEmpty()) { - if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + if (channels.isNotEmpty()) { + if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() + } } } @@ -448,13 +457,16 @@ fun ChannelCard( .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { + // banner, or the newest video's thumbnail for bannerless channels val banner = channel.banners.maxByOrNull { it.width } - if (banner != null) { + val coverUrl = banner?.let { absoluteBanner(it) } + ?: stats?.latestThumbUrl + if (coverUrl != null) { AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absoluteBanner(banner)) + .data(coverUrl) .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt index b7adec0..7088eb4 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -14,11 +14,16 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent */ fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { onPreviewKeyEvent { ke -> - if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && - ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER - ) { - onClick() - true + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + val code = ke.nativeKeyEvent.keyCode + val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + code == android.view.KeyEvent.KEYCODE_ENTER || + code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER || + code == android.view.KeyEvent.KEYCODE_SPACE + if (activates) { + onClick() + true // consumed in preview -> the platform/card path never double-fires + } else false } else false } } \ No newline at end of file From 564f6444e7ac12114929764509572c2043c7d091 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 49/53] ui: Use the JTube TV logo for the launcher icon and TV banner Replace the placeholder red play-square with the real logo (the orange TV from https://jeena.net/jtube-logo.svg) converted to vector drawables. The old placeholder still lives on in git history; the launcher now matches the website branding. --- app/src/main/res/drawable/app_banner.xml | 51 ++++++++++++++----- app/src/main/res/drawable/icon_foreground.xml | 39 ++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml index 29ba394..5d78e70 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -7,16 +7,43 @@ - - - - + + + + + + + + + + + + + \ 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 index 74dc88e..28febb9 100644 --- a/app/src/main/res/drawable/icon_foreground.xml +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -5,13 +5,42 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.6" + android:scaleY="0.6" + android:translateX="27.5" + android:translateY="26.5"> + android:fillColor="#F2690D" + android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" /> + + android:pathData="M15.0279 42.3829 c 14.4103 -2.9379 34.4644 -4.7509 48.0973 -4.21 5.1043 0.2025 9.3683 4.8586 9.1618 9.7687 -0.2835 6.7398 -1.0404 18.8371 -1.477 25.8761 -0.3086 4.9748 -4.3214 9.3362 -8.8365 9.4034 -14.5318 0.2161 -28.9546 -0.025 -43.8451 0.6671 -4.5318 0.2106 -8.7268 -3.2379 -9.2264 -7.5956 C 7.993 68.3645 6.7849 60.7535 5.9079 52.9211 c -0.4974 -4.4414 3.7431 -9.442 9.1201 -10.5382 z" /> + + + + + + + + \ No newline at end of file From ee5748b68d9f578b59ab33ae36c18c51f07cc2aa Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:26:47 +0000 Subject: [PATCH 50/53] ui: Use the JTube TV logo for the launcher icon and TV banner Replace the placeholder red play-square with the real logo (the orange TV from https://jeena.net/jtube-logo.svg) converted to vector drawables. The old placeholder still lives on in git history; the launcher now matches the website branding. --- app/src/main/res/drawable/app_banner.xml | 51 ++++++++++++++----- app/src/main/res/drawable/icon_foreground.xml | 39 ++++++++++++-- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml index 29ba394..5d78e70 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -7,16 +7,43 @@ - - - - + + + + + + + + + + + + + \ 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 index 74dc88e..28febb9 100644 --- a/app/src/main/res/drawable/icon_foreground.xml +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -5,13 +5,42 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.6" + android:scaleY="0.6" + android:translateX="27.5" + android:translateY="26.5"> + android:fillColor="#F2690D" + android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" /> + + android:pathData="M15.0279 42.3829 c 14.4103 -2.9379 34.4644 -4.7509 48.0973 -4.21 5.1043 0.2025 9.3683 4.8586 9.1618 9.7687 -0.2835 6.7398 -1.0404 18.8371 -1.477 25.8761 -0.3086 4.9748 -4.3214 9.3362 -8.8365 9.4034 -14.5318 0.2161 -28.9546 -0.025 -43.8451 0.6671 -4.5318 0.2106 -8.7268 -3.2379 -9.2264 -7.5956 C 7.993 68.3645 6.7849 60.7535 5.9079 52.9211 c -0.4974 -4.4414 3.7431 -9.442 9.1201 -10.5382 z" /> + + + + + + + + \ No newline at end of file From b10d32382ffb098e0cb5aacf0cdf9a44ec2eba95 Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:27:08 +0000 Subject: [PATCH 51/53] docs: Drop instance-specific storage-security notes The public repo must not document this instance's object-storage access-control posture; findings like that belong in private ops notes. The engineering guidance (never rely on URL obscurity, always request file tokens) stays. --- .gitignore | 4 +++- AGENTS.md | 7 ++++--- BACKLOG.md | 14 ++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index f89a0ab..8685299 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ /app/build /captures .externalNativeBuild -.cxxreports/*-shots/ +.cxx +reports/*-shots/ +reports/*.md diff --git a/AGENTS.md b/AGENTS.md index 1e68716..bc283be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a 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). - - tube.jeena.net itself streams non-public media from a **publicly readable R2 - bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on - that — always request the file token for non-public videos. + - 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.) ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 947bade..9019380 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - Logout / switch account: sign-out entry exists; add switch-account (re-login screen without losing scroll state). - Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. -- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: - instance thumbnail paths are publicly readable (HTTP 200 without auth); add a - fallback poster only if other instances behave differently. -- Security note for tube.jeena.net: object-storage (R2) media for private test - videos is publicly readable without token ("private" only via URL obscurity). - Flag to the instance admin; the app must not rely on this laxness. +- Thumbnails of private/internal videos may 404 — on some instances thumbnail + paths are publicly readable while on others they need auth; add a fallback + poster only if needed. +- Security note (verified live against tube.jeena.net): some instances serve + non-public media / thumbnails from object storage with only URL-based access + control. The app must not rely on that laxness; always send auth headers + / file tokens for non-public media. (Detail intentionally omitted from the + public repo — report instance-specific findings privately.) - 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. From 73b1c86790f761a19a67a26d4b98fbb3f363cbeb Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:27:08 +0000 Subject: [PATCH 52/53] docs: Drop instance-specific storage-security notes The public repo must not document this instance's object-storage access-control posture; findings like that belong in private ops notes. The engineering guidance (never rely on URL obscurity, always request file tokens) stays. --- .gitignore | 4 +++- AGENTS.md | 7 ++++--- BACKLOG.md | 14 ++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index f89a0ab..8685299 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ /app/build /captures .externalNativeBuild -.cxxreports/*-shots/ +.cxx +reports/*-shots/ +reports/*.md diff --git a/AGENTS.md b/AGENTS.md index 1e68716..bc283be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a 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). - - tube.jeena.net itself streams non-public media from a **publicly readable R2 - bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on - that — always request the file token for non-public videos. + - 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.) ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 08cad82..5ac444d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - Logout / switch account: sign-out entry exists; add switch-account (re-login screen without losing scroll state). - Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. -- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net: - instance thumbnail paths are publicly readable (HTTP 200 without auth); add a - fallback poster only if other instances behave differently. -- Security note for tube.jeena.net: object-storage (R2) media for private test - videos is publicly readable without token ("private" only via URL obscurity). - Flag to the instance admin; the app must not rely on this laxness. +- Thumbnails of private/internal videos may 404 — on some instances thumbnail + paths are publicly readable while on others they need auth; add a fallback + poster only if needed. +- Security note (verified live against tube.jeena.net): some instances serve + non-public media / thumbnails from object storage with only URL-based access + control. The app must not rely on that laxness; always send auth headers + / file tokens for non-public media. (Detail intentionally omitted from the + public repo — report instance-specific findings privately.) - 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. From 1f06c9f41ce9fc392a815d33fd0f8465a56f983a Mon Sep 17 00:00:00 2001 From: Jeena Date: Mon, 12 Oct 2026 00:33:42 +0000 Subject: [PATCH 53/53] docs: Remove the test account name from the public repo The account name identifies a real, seeded account on the instance; the public source doesn't need it to make sense. Credentials were never in the repo and still live only in sops. --- BACKLOG.md | 2 +- TESTS.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 9019380..f9e6182 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -51,7 +51,7 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme. - Verify `searchTarget=local` channel search behaves well for instances without federation; consider `search/video-channels` endpoint as an alternative. -- (Verified 2026-10-10 via the test account account) history subscriptions + watching endpoints +- (Verified 2026-10-10 via the test account) history subscriptions + watching endpoints behave as documented; upload seeding uses **flat multipart form fields** on `/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for seeding test data, the app itself doesn't upload. diff --git a/TESTS.md b/TESTS.md index 46dbb1b..4f5dd87 100644 --- a/TESTS.md +++ b/TESTS.md @@ -13,10 +13,11 @@ when everything passes (playback, focus and UI states are untestable by script). ## Test environment / prerequisites - Instance: any PeerTube (baseline: , v8.3.1, media on R2 object storage). -- Account: `the test account` (sops: `~/.agents/secrets/jtube.env`). Credentials resolved +- Account: a dedicated test account (credentials via sops from `~/.agents/secrets/jtube.env`). + Credentials resolved at login; **do not store the password in the device during tests** — it's typed once on the setup screen (which is exactly feature 0). -- Seeded content (tagged `jtube-tv-test`, channel `the test channel`): +- Seeded content (tagged `jtube-tv-test`, in the test account's channel): - `JTube TV Test 1 - Sintel Teaser` **Private** (~52 s) - `JTube TV Test 2 - Caminandes Llamigos` **Private** (~2:30) - `JTube TV Test 3 - Cosmos Laundromat` **Private** (~50 s) @@ -57,7 +58,7 @@ APK version + device. - [ ] T9 Long video title truncates (2 lines max) without breaking the row layout; long channel names truncate. ### 2 — Channels -- [ ] T10 "Channels" lists account's own channels (`the test channel`), avatar when present, video count. +- [ ] T10 "Channels" lists the account's own channels, avatar when present, video count. - [ ] T11 Click channel → that channel's videos list; open and play one; back returns to the channel's list (not the channels screen lost state). - [ ] T12 Remote channel via direct handle is out of MVP scope, but local-channel search (when implemented) must be re-tested: `searchTarget=local` ignores federated results.