Compare commits

...

27 commits

Author SHA1 Message Date
1f06c9f41c 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.
2026-10-12 00:33:42 +00:00
b10d32382f 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.
2026-10-12 00:27:08 +00:00
564f6444e7 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.
2026-10-12 00:26:47 +00:00
2c2d2dd25a 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.
2026-10-12 00:26:47 +00:00
013d343ae9 scripts: emulator-seed.sh — seed a fresh emulated device with the test account 2026-10-11 07:47:06 +00:00
6ef9180fa1 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).
2026-10-11 07:44:35 +00:00
906b2391c9 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.
2026-10-11 07:31:18 +00:00
f3ee72f43e 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.
2026-10-11 07:28:19 +00:00
a85ae3c124 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.
2026-10-11 07:01:27 +00:00
83c2b1b286 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.
2026-10-11 06:45:56 +00:00
8ae74058ad 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.
2026-10-11 06:13:59 +00:00
583543a26b 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.
2026-10-11 05:42:02 +00:00
7bd0a386f1 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.
2026-10-11 05:17:12 +00:00
e762c03a9c 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.
2026-10-11 05:14:32 +00:00
b7fbf757dd 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.
2026-10-11 01:15:51 +00:00
dda496aad1 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.
2026-10-11 00:48:23 +00:00
b09e2cb8a6 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.
2026-10-10 23:52:01 +00:00
88b5b681a0 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.
2026-10-10 23:26:57 +00:00
0f8065464f 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.
2026-10-10 22:53:03 +00:00
4481244f64 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).
2026-10-10 22:29:00 +00:00
db9990905f 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.
2026-10-10 22:24:06 +00:00
6cd8417c7d 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.
2026-10-10 22:16:29 +00:00
14cd3d3976 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.
2026-10-10 12:41:55 +00:00
9d89c1c060 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.
2026-10-10 12:23:11 +00:00
43056065e1 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.
2026-10-10 11:29:08 +00:00
83dde8ba74 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.
2026-10-10 11:02:22 +00:00
764fa8b34c 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.
2026-10-10 10:53:49 +00:00
32 changed files with 3060 additions and 313 deletions

4
.gitignore vendored
View file

@ -7,4 +7,6 @@
/app/build
/captures
.externalNativeBuild
.cxx
.cxx
reports/*-shots/
reports/*.md

View file

@ -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,15 +53,26 @@ 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": <seconds> }` — 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": <seconds> }`
→ HTTP 204 (verified). This is what populates server-side history AND the stored
playback position. Report every ~20 s of wall-clock playback once currentTime ≥ 5 s.
- Resume-from-server possible via `GET /api/v1/videos/{shortUuid}?userHistory=true`
→ `userHistory: { currentTime, currentPercent }` (verified)
- `GET /api/v1/videos/{shortUuid}` for playback URLs: prefer
`streamingPlaylists[].playlistUrl` (HLS) else `files[].fileUrl`.
- Private/internal media: attach `Authorization: Bearer` to playlist, segment and file
requests (configured on the Media3 `HttpDataSource.Factory`).
requests (configured on the Media3 `HttpDataSource.Factory`) AND — for instances
whose media lives in a private object-storage bucket — pass the short-lived
`POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs
(response shape: `{ files: { token, expires } }`; verified live).
- Some instances stream non-public media from object-storage hosts with only
URL-based access control — never rely on that; always request and use the
file token for non-public videos. (Instance-specific findings stay out of
the public repo and live in the private ops notes/agent-local instructions.)
## Conventions

View file

@ -2,30 +2,59 @@
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, next/previous video from list,
auto-focus currently watched item on history screen.
- Thumbnails of private/internal videos may 404 — probe server behavior and add a
fallback (generic poster) when required.
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 — 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.
- 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) 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.

96
TESTS.md Normal file
View file

@ -0,0 +1,96 @@
# 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: <https://tube.jeena.net>, v8.3.1, media on R2 object storage).
- 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`, 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)
- 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 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.
### 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. 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
- [ ] 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.

View file

@ -35,6 +35,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

View file

@ -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
@ -11,16 +13,29 @@ 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))
}
}
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()
}

View file

@ -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) {

View file

@ -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
@ -67,10 +69,86 @@ class PeerTubeRepository(
expiresAtMillis = System.currentTimeMillis() +
(token.expiresIn ?: 86400L) * 1000L,
)
// 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 {
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.instanceName = try {
meApi.serverConfig().instance.name
} catch (_: Exception) { null }
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
}
}
suspend fun me(): Result<UserDto> = runCatching { network.api.me() }
suspend fun serverConfig(): Result<ServerConfigDto> = runCatching { network.api.serverConfig() }
/** Display name of a (possibly foreign) instance, via that instance's /config. */
suspend fun instanceName(rawHost: String): Result<String> = 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<net.jeena.jtubetv.data.api.ChannelStats> =
runCatching {
var start = 0L
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
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, latestThumb)
}
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<VideoListResponse> =
@ -96,6 +174,19 @@ class PeerTubeRepository(
suspend fun localChannels(start: Long, count: Int, search: String? = null): Result<VideoChannelListResponse> =
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<Pair<List<VideoChannelDto>, Long>> =
runCatching {
val response = network.api.channels(search = null, start = start, count = count)
response.data to response.total
}
suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
runCatching { network.api.videoChannel(handle) }
suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username)
@ -104,12 +195,20 @@ class PeerTubeRepository(
// ---- playback ----------------------------------------------------------
suspend fun video(shortUuid: String): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid) }
suspend fun video(shortUuid: String, userHistory: Boolean? = null): Result<VideoDetailDto> =
runCatching { network.api.video(shortUuid, userHistory = userHistory) }
suspend fun reportWatching(shortUuid: String, currentTimeSeconds: Int): Result<Unit> =
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<String> =
runCatching { network.api.videoToken(shortUuid).files.token }
// ---- media URLs --------------------------------------------------------
/**
@ -132,6 +231,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 =

View file

@ -60,6 +60,14 @@ data class VideoListResponse(
val data: List<VideoDto>,
)
@Serializable
data class ChannelOwnerAccount(
val name: String,
val host: String? = null,
@SerialName("displayName") val displayName: String? = null,
val avatars: List<Avatar> = emptyList(),
)
@Serializable
data class VideoChannelDto(
val name: String,
@ -67,7 +75,10 @@ data class VideoChannelDto(
val host: String,
val description: String? = null,
val avatars: List<Avatar> = emptyList(),
val banners: List<Avatar> = emptyList(),
val videosCount: Long = 0,
val followersCount: Long = 0,
val ownerAccount: ChannelOwnerAccount? = null,
)
@Serializable
@ -91,6 +102,11 @@ data class StreamingPlaylist(
val files: List<VideoFile> = emptyList(),
)
@Serializable
data class UserHistoryDto(
val currentTime: Int? = null,
)
@Serializable
data class VideoDetailDto(
val id: Long,
@ -107,6 +123,7 @@ data class VideoDetailDto(
val channel: VideoChannelSummary,
val files: List<VideoFile> = emptyList(),
val streamingPlaylists: List<StreamingPlaylist> = emptyList(),
val userHistory: UserHistoryDto? = null,
)
@Serializable
@ -115,7 +132,39 @@ 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 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,
/** Thumbnail of the newest video — banner fallback for channels without one. */
val latestThumbUrl: String? = null,
)
@Serializable
data class UserDto(
val username: String,
val displayName: String? = null,
val avatars: List<Avatar> = emptyList(),
)

View file

@ -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,
@ -95,6 +100,7 @@ interface PeerTubeApi {
@GET("videos/{shortUuid}")
suspend fun video(
@Path("shortUuid") shortUuid: String,
@Query("userHistory") userHistory: Boolean? = null,
): VideoDetailDto
@POST("videos/{shortUuid}/watching")
@ -103,6 +109,15 @@ 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
@GET("config")
suspend fun serverConfig(): ServerConfigDto
}

View file

@ -48,6 +48,25 @@ 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()
/** 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()
/** 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()

View file

@ -19,6 +19,13 @@ class AppViewModel(private val repository: PeerTubeRepository = AppGraph.reposit
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Loading)
val sessionState: StateFlow<SessionState> = _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<String?>(null)
val loggingIn = MutableStateFlow(false)
@ -30,18 +37,37 @@ 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
sessionEpoch.value += 1
} else {
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()
_sessionState.value = SessionState.NeedsSetup
sessionEpoch.value += 1
}
}

View file

@ -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<String>()
fun videoThumbnails(videos: List<VideoDto>, 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<VideoChannelDto>, 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)
}
}

View file

@ -27,6 +27,9 @@ abstract class PagedListViewModel<T> : ViewModel() {
protected abstract suspend fun fetch(start: Long, count: Int): Result<List<T>>
/** Warm image caches for just-loaded rows so later scrolls don't fetch from network. */
protected open fun onChunkLoaded(chunk: List<T>) {}
fun loadMore(force: Boolean = false) {
if (inFlight || (!force && exhausted)) return
inFlight = true
@ -39,6 +42,7 @@ abstract class PagedListViewModel<T> : ViewModel() {
exhausted = chunk.size < pageSize
items = items + chunk
loadingInitial = false
onChunkLoaded(chunk)
},
onFailure = { e ->
if (items.isEmpty()) {
@ -54,6 +58,27 @@ abstract class PagedListViewModel<T> : 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

View file

@ -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,23 @@ 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<net.jeena.jtubetv.data.api.VideoChannelDto?>(
null,
)
private set
override fun onChunkLoaded(chunk: List<VideoListItem>) {
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<List<VideoListItem>> {
return when (val s = source) {
is ListSource.LocalVideos ->
@ -49,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 }

View file

@ -1,51 +1,263 @@
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.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.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.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
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.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
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.PagedListViewModel
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
class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoChannelDto>() {
/**
* Channels, SmartTube-style: one row per LOCAL user.
*
* - Row 1: the logged-in user ("<display name>'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<VideoChannelDto>,
)
override suspend fun fetch(start: Long, count: Int): Result<List<VideoChannelDto>> =
repository.localChannels(start, count).map { it.data }
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var user by mutableStateOf<UserDto?>(null)
private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set
var userRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
/** "name@host" -> stats; filled progressively in the background. */
var stats by mutableStateOf<Map<String, ChannelStats>>(emptyMap())
private set
/** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */
var platformNames by mutableStateOf<Map<String, String>>(emptyMap())
private set
private var allChannels: List<VideoChannelDto> = emptyList()
/** Channels from followed instances, grouped per platform. */
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>()
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<VideoChannelDto>()
var start = 0L
var total: Long? = null
var retries = 0
while (total == null || start < total) {
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 }
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)
}
ImagePrefetcher.channelArt(all + userChannels)
// 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"
} finally {
loading = false
}
}
}
/** Fetch stats for [channels], re-sorting rows as data lands. */
private fun channelStatsLoop(channels: List<VideoChannelDto>, 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()
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)
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<VideoChannelDto> { 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<VideoChannelDto> { 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<UserGroup> { 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 } },
)
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 ?: ""
companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ChannelsViewModel(AppGraph.repository) as T
ChannelsViewModel(AppGraph.tokenStore) as T
}
}
}
@ -53,101 +265,290 @@ class ChannelsViewModel(private val repository: PeerTubeRepository) : PagedListV
@Composable
fun ChannelsScreen(
onOpenChannel: (handle: String) -> Unit,
viewModel: ChannelsViewModel = viewModel(factory = ChannelsViewModel.factory),
epoch: Int = 0,
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.userRows.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
) { androidx.compose.material3.CircularProgressIndicator() }
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)
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)
}
}
}
@Composable
private fun ChannelsList(
items: List<VideoChannelDto>,
viewModel: ChannelsViewModel,
onOpenChannel: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
itemsIndexed(items) { index, channel ->
ChannelCard(
channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
)
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() }
}
}
if (!viewModel.exhausted) {
item {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
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) {
RowHeader(
avatar = group.avatar,
label = group.label,
)
}
item(key = "row-" + group.ownerName) {
ChannelRow(
group.channels,
onOpenChannel,
onOpenDrawer,
focused = !focusHandedOut.value &&
group == viewModel.userRows.firstOrNull(),
onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] },
)
}
}
}
}
}
}
/** Row header: user icon + "<name>'s Channels". */
@Composable
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
private fun RowHeader(avatar: Avatar?, label: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) {
val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) {
val url = avatar.fileUrl?.let { if (it.startsWith("http")) it else null }
?: (AppGraph.tokenStore.baseUrl + avatar.path)
AsyncImage(
model = url,
contentDescription = channel.displayName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(CircleShape).background(Color(0xFF222222)),
)
}
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = channel.displayName.ifBlank { channel.name },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${channel.host} · ${channel.name} · ${channel.videosCount} videos",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8A8A8A),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
if (avatar != null) {
AsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteAvatar(avatar))
.size(80)
.build(),
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 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, eased scroll-in; LEFT at row start -> drawer. */
@Composable
private fun ChannelRow(
channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {},
focused: Boolean,
onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?,
) {
val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { mutableStateOf(-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)
}
}
if (focused) {
LaunchedEffect(channels.isNotEmpty()) {
if (channels.isNotEmpty()) {
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
}
}
}
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(channels, key = { _, c -> c.name + "@" + c.host }) { index, channel ->
val fr = remember { FocusRequester() }
focusRequesters[index] = fr
ChannelCard(
channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
stats = stats(channel),
platformName = platformName(channel),
onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier.focusRequester(fr),
)
}
}
}
/**
* 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: ChannelStats? = null,
platformName: String? = null,
onLeftAtStart: (() -> Unit)? = null,
) {
var cardFocused by remember { mutableStateOf(false) }
Card(
onClick = onClick,
modifier = modifier
.width(rowCardWidth())
.padding(6.dp)
.dpadCenterActivatable(onClick)
.onFocusChanged { cardFocused = it.isFocused }
.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),
),
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(Color(0xFF222222)),
) {
// banner, or the newest video's thumbnail for bannerless channels
val banner = channel.banners.maxByOrNull { it.width }
val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl
if (coverUrl != null) {
AsyncImage(
model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(coverUrl)
.size(480, 270) // same key as prefetch
.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(absoluteAvatar(avatar))
.size(80, 80)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(8.dp)
.size(40.dp)
.clip(CircleShape)
.background(Color(0xFF333333)),
)
}
}
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = channel.displayName.ifBlank { channel.name },
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
color = if (cardFocused) Color(0xFF101010) else Color.White,
)
Text(
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(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,
) {
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)
}
}

View file

@ -0,0 +1,327 @@
package net.jeena.jtubetv.ui.screens
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.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.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
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
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.screens.list.VideoCard
/** Compact SmartTube-style card: ~4 and a bit visible per full-width row. */
@Composable
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?)
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
private set
var continueLoading by mutableStateOf(true)
private set
var subItems by mutableStateOf<List<VideoDto>>(emptyList())
private set
private var loaded = false
val recent = RecentVideosViewModel(repository)
private fun fetchSubs() {
viewModelScope.launch {
val subs = repository.subscriptionVideos(0, 12).getOrNull()?.data ?: emptyList()
subItems = subs
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
}
}
/** 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()
fetchSubs()
recent.refresh()
}
private fun fetchContinue() {
fetchSubs()
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.
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
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
}
}
companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
HomeRowsViewModel(AppGraph.repository) as T
}
}
}
/** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
override fun onChunkLoaded(chunk: List<VideoDto>) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
}
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
repository.localVideos(start, count).map { it.data }
companion object {
fun factory(repo: PeerTubeRepository): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
RecentVideosViewModel(repo) as T
}
}
}
/**
* 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,
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) }
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
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),
)
}
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),
)
}
} else {
FocusScrollRow(
videos = viewModel.continueItems.map { it.video },
progressOf = { shortUUID ->
viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress
},
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
clickedIndex = continueRestore,
restoreToken = restoreToken,
upToTopBar = upToTopBar,
)
}
}
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 {
FocusScrollRow(
videos = recentItems,
progressOf = { null },
onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer,
clickedIndex = recentRestore,
restoreToken = restoreToken,
) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() }
}
}
}
}
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 = { },
)
}
}
}
}
/**
* 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
fun FocusScrollRow(
videos: List<VideoDto>,
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
upToTopBar: (() -> Unit)? = null, // first content row only
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) {
val rowState = rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
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()
}
}
}
}
// 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
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, key = { _, video -> "v" + video.id }) { index, video ->
val fr = remember { FocusRequester() }
focusRequesters[index] = fr
VideoCard(
video = video,
onClick = {
clickedIndex?.intValue = index
onOpenVideo(video)
},
modifier = Modifier
.width(rowCardWidth())
.focusRequester(fr),
showPrivacy = false,
compact = true,
progress = progressOf(video.shortUUID),
onLeftAtStart = if (index == 0) onLeftAtStart else null,
onUpToTopBar = upToTopBar,
onFocused = { focusedIndex = index },
)
afterItem(index, video)
}
}
}

View file

@ -1,34 +1,64 @@
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
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.AccountCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Subscriptions
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.Logout
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.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.focus.onFocusChanged
import androidx.compose.ui.focus.onFocusChanged
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
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
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
import net.jeena.jtubetv.ui.screens.list.VideoListScreen
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
enum class Destination(val title: String) {
LocalVideos("Videos"),
Home("Home"),
MyVideos("My videos"),
Channels("Channels"),
Search("Search"),
@ -36,96 +66,408 @@ enum class Destination(val title: String) {
History("History"),
}
/** Home = sidebar (D-pad navigable) + content pane. */
/**
* 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() {
var destination by rememberSaveable { mutableStateOf(Destination.LocalVideos) }
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<String?>(null) } // handle
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
// NOT saveable: launching the app should never resume a half-watched player session.
var playingVideo by remember { mutableStateOf<String?>(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) }
val searchFocus = remember { FocusRequester() }
if (playingVideo != null) {
PlayerScreen(
shortUuid = playingVideo!!,
onBack = { playingVideo = null },
)
return
// 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++
}
if (destination == Destination.Channels && channelDetails != null) {
VideoListScreen(
source = ListSource.Channel(channelDetails!!, channelDetails!!),
onOpenVideo = { playingVideo = it.shortUUID },
)
return
// 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() }
}
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) },
)
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()
}
}) {
Row(modifier = Modifier.fillMaxSize()) {
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.
TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen },
searchFocus = searchFocus,
)
Box(
modifier = Modifier
.fillMaxSize()
// TV overscan safety margins on the content pane.
.padding(start = 8.dp, end = 32.dp, top = 8.dp),
) {
when (destination) {
Destination.LocalVideos ->
VideoListScreen(
source = ListSource.LocalVideos,
Destination.Home ->
HomeRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
)
Destination.MyVideos ->
VideoListScreen(
source = ListSource.MyVideos,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
)
Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle },
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
)
Destination.Search ->
VideoListScreen(
source = ListSource.Search,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
)
Destination.Subscriptions ->
VideoListScreen(
source = ListSource.Subscriptions,
SubscriptionRowsScreen(
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
)
Destination.History ->
VideoListScreen(
source = ListSource.History,
onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken,
epoch = epoch,
onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
)
}
}
}
}
// 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(
shortUuid = playingVideo!!,
onBack = { playingVideo = null },
)
}
}
}
private fun Destination.icon(): androidx.compose.ui.graphics.vector.ImageVector =
private fun Destination.icon(): 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
Destination.Subscriptions -> Icons.Filled.Subscriptions
Destination.History -> Icons.Filled.History
}
}
/** Top bar: red round search + account avatar (opens the menu), SmartTube-style. */
@Composable
private fun TopBar(
onSearchClick: () -> Unit,
onAccountClick: () -> Unit,
searchFocus: FocusRequester,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 10.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp),
) {
RoundTopButton(
bg = Color(0xFFCE2B2B),
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) }
Row(
modifier = Modifier
.onFocusChanged { accountFocused.value = it.isFocused }
.background(Color.Transparent)
.then(
if (accountFocused.value) Modifier.border(3.dp, Color.White, CircleShape)
else Modifier
)
.focusable()
.dpadCenterActivatable(onAccountClick)
.clickable(onClick = onAccountClick),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) {
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.userDisplayName
?: AppGraph.tokenStore.username?.substringBefore('@') ?: "Account",
color = Color(0xFFB0B0B0),
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 8.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "JTube TV",
color = Color(0xFF9A9A9A),
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
}
@Composable
private fun RoundTopButton(
bg: Color,
icon: ImageVector,
desc: String,
onClick: () -> Unit,
focusRequester: FocusRequester? = null,
) {
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
)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.focusable()
.dpadCenterActivatable(onClick)
.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.
*/
@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)
.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",
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 the drawer
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
)
}
}
@Composable
private fun MenuDrawerItem(
icon: ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
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()
.onFocusChanged { focused = it.isFocused }
.background(
when {
focused -> Color.White
selected -> Color(0x33CE2B2B)
else -> Color.Transparent
}
)
.focusable()
.dpadCenterActivatable(onClick)
.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,
)
}
}

View file

@ -1,18 +1,44 @@
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
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.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 androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@ -20,9 +46,11 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
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
@ -30,13 +58,20 @@ 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
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,
/** Server-recorded position; resume unless (almost) finished. */
val resumeAtSeconds: Int? = null,
) : PlayerUiState
data class Error(val message: String) : PlayerUiState
}
@ -48,19 +83,30 @@ class PlayerViewModel(
private val _state = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading)
val state: StateFlow<PlayerUiState> = _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 (refresh on 401 via authenticator).
repository.me()
val result = repository.video(shortUuid, userHistory = true)
result.fold(
onSuccess = { detail ->
val play = repository.playoutUrl(detail)
if (play == null) {
_state.value = PlayerUiState.Error("No playable stream found")
} else {
_state.value = PlayerUiState.Ready(detail, play.url, play.isHls)
// 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()
}
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 ->
@ -70,10 +116,23 @@ class PlayerViewModel(
}
}
fun reportWatching(shortUuid: String, currentTimeSeconds: Int) {
/** 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) }
}
fun retry() {
val uuid = currentShortUuid ?: return
_state.value = PlayerUiState.Loading
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 {
@ -85,15 +144,18 @@ 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 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),
),
) {
@ -108,8 +170,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)
}
}
@ -121,73 +183,290 @@ 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,
)
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()
},
) { 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() }
var paused by remember { mutableStateOf(false) }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
val player = remember {
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)
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,
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(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)
val resume = s.resumeAtSeconds
if (resume != null) {
player.setMediaItem(item, resume * 1000L)
Toast.makeText(
context,
"Resuming at " + resume / 60 + ":" + "%02d".format(resume % 60),
Toast.LENGTH_LONG,
).show()
} else {
player.setMediaItem(item)
}
player.prepare()
player.playWhenReady = true
onDispose { player.release() }
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()
}
}
// Periodic watching report (server-side history).
// Single handler for the periodic watch report (the old code leaked one per video).
DisposableEffect(player) {
var lastReported = 0L
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) {
viewModel.reportWatching(shortUuid, (position / 1000).toInt())
val sinceLast = now - lastReported
if (position >= 5_000 && sinceLast >= 20_000) {
viewModel.persistExit(shortUuid, (position / 1000).toInt())
lastReported = now
}
}
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) }
}
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
this.player = player
controllerShowTimeoutMs = 4000
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
}
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_REWIND -> {
player.seekTo((player.currentPosition - 10_000).coerceAtLeast(0))
true
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
player.seekTo(player.currentPosition + 10_000)
true
}
else -> false
}
},
modifier = Modifier.fillMaxSize().background(Color.Black),
)
} else false
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(keyFocus)
.focusable()
.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))
}
}
/** 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)
}
}

View file

@ -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
@ -15,11 +16,19 @@ 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
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
@ -34,8 +43,17 @@ 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
// 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<String?>(null) }
Column(
modifier = Modifier
@ -58,30 +76,120 @@ fun SetupScreen(
onValueChange = { url.value = it },
label = { Text("PeerTube instance URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
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 -> { userFocus.requestFocus(); true }
else -> false
}
}
.fillMaxWidth(),
colors = fieldColors(),
)
TextField(
value = username.value,
onValueChange = { username.value = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
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 -> { passFocus.requestFocus(); true }
Key.DirectionUp -> { urlFocus.requestFocus(); true }
else -> false
}
}
.fillMaxWidth(),
colors = fieldColors(),
)
TextField(
value = password.value,
onValueChange = { password.value = it },
label = { Text("Password") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
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.DirectionUp -> { userFocus.requestFocus(); true }
else -> false
}
}
.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(),
modifier = Modifier.fillMaxWidth(),
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()
.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))
@ -92,3 +200,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),
)

View file

@ -0,0 +1,170 @@
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<VideoDto>() {
override fun onChunkLoaded(chunk: List<VideoDto>) {
ImagePrefetcher.videoThumbnails(chunk)
}
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
repository.subscriptionVideos(start, count).map { it.data }
}
private data class SubGroup(
val channel: VideoChannelSummary,
val startIndex: Int,
val videos: List<VideoDto>,
)
private fun buildGroups(items: List<VideoDto>): List<SubGroup> {
val groups = mutableListOf<SubGroup>()
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 = {},
upToTopBar: (() -> Unit)? = null,
viewModel: SubscriptionRowsViewModel = viewModel(
key = "subsrows$epoch",
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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,
upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() },
) { rowIndex, _ ->
if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) {
viewModel.loadMore()
}
}
}
}
}
}

View file

@ -0,0 +1,29 @@
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) {
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
}
}

View file

@ -3,100 +3,187 @@ 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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.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
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
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,
/** 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,
/** 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,
) {
var cardFocused by remember { androidx.compose.runtime.mutableStateOf(false) }
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),
.padding(6.dp)
.dpadCenterActivatable(onClick)
.onFocusChanged {
cardFocused = it.isFocused
if (it.isFocused) onFocused?.invoke()
}
.onPreviewKeyEvent { ke ->
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(
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,
),
),
) {
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()
.aspectRatio(16f / 9f)
.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 = req,
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 = if (compact) 1 else 2,
minLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = if (cardFocused) Color(0xFF101010) else Color.White,
)
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 = if (cardFocused) Color(0xFF6A6A6A) else 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 +193,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 = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
)
}
}
}
@ -136,10 +222,31 @@ 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)
views >= 1_000 -> "%.1fK views".format(Locale.US, views / 1_000.0)
views == 1L -> "1 view"
else -> "$views views"
}
}
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"
days >= 30 -> "${days / 30}mo ago"
days >= 1 -> "${days}d ago"
age.toHours() >= 1 -> "${age.toHours()}h ago"
else -> "just now"
}
} catch (_: DateTimeParseException) {
""
}
}

View file

@ -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,18 +15,25 @@ 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.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
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
@ -34,12 +42,43 @@ import net.jeena.jtubetv.ui.list.VideoListViewModel
fun VideoListScreen(
source: ListSource,
onOpenVideo: (VideoDto) -> Unit,
restoreToken: Int = 0,
epoch: Int = 0,
onOpenDrawer: () -> Unit = {},
upToTopBar: (() -> Unit)? = null,
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 = androidx.compose.runtime.remember(items.size, epoch) {
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
}
// 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(epoch) { mutableStateOf(-1) }
LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) runCatching { requesters[0]?.requestFocus() ?: 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] ?: requesters[0] ?: initialFocus
runCatching { target.requestFocus() }
lastOpenedIndex = -1
}
}
// Scroll position survives destination switches (per source), not just player exits.
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
inputs = arrayOf(epoch),
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
when (source) {
is ListSource.Search -> SearchHeader(viewModel)
@ -82,16 +121,29 @@ fun VideoListScreen(
return
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
itemsIndexed<VideoListItem>(items) { index, item ->
LazyVerticalGrid(
// 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),
) {
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video
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,
compact = true,
onLeftAtStart = if (index == 0) onOpenDrawer else null,
onUpToTopBar = upToTopBar?.takeIf { index < 4 },
modifier = Modifier
.focusRequester(fr),
)
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.loadMore() }
@ -110,13 +162,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) },
@ -127,6 +194,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

View file

@ -7,16 +7,43 @@
<path
android:fillColor="#101010"
android:pathData="M0,0h320v180h-320z" />
<path
android:fillColor="#CE2B2B"
android:pathData="M40,40h64a8,8 0 0 1 8,8v64a8,8 0 0 1 -8,8h-64a8,8 0 0 1 -8,-8v-64a8,8 0 0 1 8,-8z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M60,62l26,20l-26,20z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M128,76h160v10h-160z" />
<path
android:fillColor="#66FFFFFF"
android:pathData="M128,100h120v8h-120z" />
<group
android:scaleX="1.2"
android:scaleY="1.2"
android:translateX="106.9"
android:translateY="34.9">
<path
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" />
<path
android:fillColor="#F2690D"
android:pathData="M17.5209 35.292 c 15.3522 -3.0444 37.132 -5.2959 52.8675 -3.6599 5.1194 0.5323 9.9726 3.4778 13.4121 7.6102 3.4501 4.1452 5.162 9.076 4.575 13.53 -1.0211 7.7481 -2.1843 15.0762 -3.1707 22.7607 -0.5566 4.3363 -2.8555 8.1041 -5.9399 10.9748 -3.0835 2.8699 -7.1688 5.018 -11.413 5.1055 -15.7693 0.3252 -31.5349 0.3254 -47.3137 0.0128 -4.2188 -0.0836 -8.0762 -2.0481 -11.0163 -4.6009 -2.9365 -2.5497 -5.3231 -5.9933 -5.9481 -10.0185 -1.1112 -7.1561 -2.4617 -14.1198 -3.4669 -21.3147 -0.5774 -4.1328 1.2199 -8.6697 4.5294 -12.5508 3.2903 -3.8584 7.9109 -6.8627 12.8846 -7.849 z" />
<path
android:fillColor="#FFFFFF"
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" />
<path
android:fillColor="#21747E"
android:pathData="M14.7033 44.4903 c 13.9881 -2.839 33.2434 -4.5158 46.2783 -4.2075 4.793 0.1134 8.7611 4.3804 8.5942 9.0519 -0.2357 6.5991 -0.822 17.8255 -1.2054 24.8051 -0.264 4.805 -4.0527 9.0322 -8.3523 9.1149 -14.0043 0.2692 -27.932 -0.0544 -42.2991 0.6531 -4.3211 0.2128 -8.3023 -3.058 -8.7719 -7.2184 -0.8526 -7.5531 -1.9989 -14.8099 -2.8643 -22.2368 -0.4906 -4.2105 3.499 -8.9228 8.6204 -9.9622 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M83.3821 54.6336 c -0.1383 1.2653 -1.437 1.9756 -2.8571 1.6554 -1.4217 -0.3206 -2.4853 -1.5718 -2.3821 -2.865 0.103 -1.2911 1.3448 -2.1176 2.816 -1.7732 1.4728 0.3448 2.5617 1.7153 2.4232 2.9828 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M80.711 72.609 c -0.1325 1.2783 -1.3091 2.1438 -2.6063 1.9539 -1.2981 -0.19 -2.2687 -1.3851 -2.1615 -2.7124 0.107 -1.3254 1.2582 -2.2397 2.594 -2.0133 1.3369 0.2265 2.3064 1.4913 2.1737 2.7719 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M82.1189 63.4565 c -0.1418 1.2519 -1.38 2.0155 -2.7341 1.7518 -1.3555 -0.2639 -2.3671 -1.4775 -2.2569 -2.7684 0.1101 -1.2889 1.3099 -2.1371 2.711 -1.8424 1.4024 0.295 2.422 1.6049 2.28 2.8589 z" />
<path
android:fillColor="#F2690D"
android:pathData="M24.3564 13.6769 c 0.3252 2.2586 -1.0941 4.3325 -3.1576 4.7592 -2.0665 0.4274 -3.9271 -0.9504 -4.1822 -3.1604 -0.255 -2.2099 1.1547 -4.4794 3.1241 -4.9311 1.973 -0.4525 3.8904 1.0729 4.2157 3.3323 z" />
<path
android:fillColor="#F2690D"
android:pathData="M65.4155 4.8373 c 0.6897 2.6269 -0.737 4.5575 -2.9291 4.5298 -2.1997 -0.0277 -4.3526 -1.9881 -5.0235 -4.5309 -0.6692 -2.5366 0.3587 -4.7994 2.5379 -4.8357 2.1875 -0.0364 4.7231 2.2031 5.4147 4.8368 z" />
<path
android:fillColor="#F2690D"
android:pathData="M22.4411 15.8196 c 0.4039 -0.5491 1.1199 -0.6467 1.6156 -0.216 5.1717 4.4937 10.1896 9.0068 15.0872 13.6473 0.4692 0.4446 0.5007 1.187 0.0731 1.663 -0.4275 0.476 -1.1483 0.5048 -1.6131 0.0681 -4.8696 -4.5749 -9.8615 -9.0137 -15.0074 -13.4151 -0.4911 -0.42 -0.5594 -1.1982 -0.1554 -1.7474 z" />
<path
android:fillColor="#F2690D"
android:pathData="M60.9631 6.1733 c 0.6295 0.5159 0.8777 1.409 0.5531 1.9942 -3.3779 6.0895 -7.181 11.901 -11.1403 17.4699 -0.3804 0.5349 -1.0986 0.6302 -1.6054 0.2202 -0.5069 -0.41 -0.6158 -1.1754 -0.2423 -1.7185 3.8871 -5.6512 7.5636 -11.5653 10.7496 -17.8077 0.306 -0.5996 1.0558 -0.674 1.6853 -0.1581 z" />
</group>
</vector>

View file

@ -5,13 +5,42 @@
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30">
android:scaleX="0.6"
android:scaleY="0.6"
android:translateX="27.5"
android:translateY="26.5">
<path
android:fillColor="#CE2B2B"
android:pathData="M4,4h40a4,4 0 0 1 4,4v40a4,4 0 0 1 -4,4h-40a4,4 0 0 1 -4,-4v-40a4,4 0 0 1 4,-4z" />
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" />
<path
android:fillColor="#F2690D"
android:pathData="M17.5209 35.292 c 15.3522 -3.0444 37.132 -5.2959 52.8675 -3.6599 5.1194 0.5323 9.9726 3.4778 13.4121 7.6102 3.4501 4.1452 5.162 9.076 4.575 13.53 -1.0211 7.7481 -2.1843 15.0762 -3.1707 22.7607 -0.5566 4.3363 -2.8555 8.1041 -5.9399 10.9748 -3.0835 2.8699 -7.1688 5.018 -11.413 5.1055 -15.7693 0.3252 -31.5349 0.3254 -47.3137 0.0128 -4.2188 -0.0836 -8.0762 -2.0481 -11.0163 -4.6009 -2.9365 -2.5497 -5.3231 -5.9933 -5.9481 -10.0185 -1.1112 -7.1561 -2.4617 -14.1198 -3.4669 -21.3147 -0.5774 -4.1328 1.2199 -8.6697 4.5294 -12.5508 3.2903 -3.8584 7.9109 -6.8627 12.8846 -7.849 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M20,21l13,10l-13,10z" />
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" />
<path
android:fillColor="#21747E"
android:pathData="M14.7033 44.4903 c 13.9881 -2.839 33.2434 -4.5158 46.2783 -4.2075 4.793 0.1134 8.7611 4.3804 8.5942 9.0519 -0.2357 6.5991 -0.822 17.8255 -1.2054 24.8051 -0.264 4.805 -4.0527 9.0322 -8.3523 9.1149 -14.0043 0.2692 -27.932 -0.0544 -42.2991 0.6531 -4.3211 0.2128 -8.3023 -3.058 -8.7719 -7.2184 -0.8526 -7.5531 -1.9989 -14.8099 -2.8643 -22.2368 -0.4906 -4.2105 3.499 -8.9228 8.6204 -9.9622 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M83.3821 54.6336 c -0.1383 1.2653 -1.437 1.9756 -2.8571 1.6554 -1.4217 -0.3206 -2.4853 -1.5718 -2.3821 -2.865 0.103 -1.2911 1.3448 -2.1176 2.816 -1.7732 1.4728 0.3448 2.5617 1.7153 2.4232 2.9828 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M80.711 72.609 c -0.1325 1.2783 -1.3091 2.1438 -2.6063 1.9539 -1.2981 -0.19 -2.2687 -1.3851 -2.1615 -2.7124 0.107 -1.3254 1.2582 -2.2397 2.594 -2.0133 1.3369 0.2265 2.3064 1.4913 2.1737 2.7719 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M82.1189 63.4565 c -0.1418 1.2519 -1.38 2.0155 -2.7341 1.7518 -1.3555 -0.2639 -2.3671 -1.4775 -2.2569 -2.7684 0.1101 -1.2889 1.3099 -2.1371 2.711 -1.8424 1.4024 0.295 2.422 1.6049 2.28 2.8589 z" />
<path
android:fillColor="#F2690D"
android:pathData="M24.3564 13.6769 c 0.3252 2.2586 -1.0941 4.3325 -3.1576 4.7592 -2.0665 0.4274 -3.9271 -0.9504 -4.1822 -3.1604 -0.255 -2.2099 1.1547 -4.4794 3.1241 -4.9311 1.973 -0.4525 3.8904 1.0729 4.2157 3.3323 z" />
<path
android:fillColor="#F2690D"
android:pathData="M65.4155 4.8373 c 0.6897 2.6269 -0.737 4.5575 -2.9291 4.5298 -2.1997 -0.0277 -4.3526 -1.9881 -5.0235 -4.5309 -0.6692 -2.5366 0.3587 -4.7994 2.5379 -4.8357 2.1875 -0.0364 4.7231 2.2031 5.4147 4.8368 z" />
<path
android:fillColor="#F2690D"
android:pathData="M22.4411 15.8196 c 0.4039 -0.5491 1.1199 -0.6467 1.6156 -0.216 5.1717 4.4937 10.1896 9.0068 15.0872 13.6473 0.4692 0.4446 0.5007 1.187 0.0731 1.663 -0.4275 0.476 -1.1483 0.5048 -1.6131 0.0681 -4.8696 -4.5749 -9.8615 -9.0137 -15.0074 -13.4151 -0.4911 -0.42 -0.5594 -1.1982 -0.1554 -1.7474 z" />
<path
android:fillColor="#F2690D"
android:pathData="M60.9631 6.1733 c 0.6295 0.5159 0.8777 1.409 0.5531 1.9942 -3.3779 6.0895 -7.181 11.901 -11.1403 17.4699 -0.3804 0.5349 -1.0986 0.6302 -1.6054 0.2202 -0.5069 -0.41 -0.6158 -1.1754 -0.2423 -1.7185 3.8871 -5.6512 7.5636 -11.5653 10.7496 -17.8077 0.306 -0.5996 1.0558 -0.674 1.6853 -0.1581 z" />
</group>
</vector>

42
scripts/bridge-emulator.sh Executable file
View file

@ -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"

112
scripts/e2e-api-check.sh Executable file
View file

@ -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 <file>.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 <name> <true/false>
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 ]

32
scripts/emulator-seed.sh Executable file
View file

@ -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'<node[^>]*focused=\"true\"[^>]*>', xml)
print('logged in as:', texts[0] if texts else '?')
print('rows/screens:', texts[1:8])"

47
scripts/fps-check.sh Executable file
View file

@ -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))
"

123
scripts/start-emulator.sh Executable file
View file

@ -0,0 +1,123 @@
#!/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.
#
# 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
#
# 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"
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
AVDM="$SDK/cmdline-tools/latest/bin/avdmanager"
AVD="${1:-jtubesc}"
PORT=5600
IMAGE="system-images;android-36;android-tv;x86_64"
[ -x "$EMU" ] || { echo "emulator not found at $EMU — install Android SDK cmdline-tools first"; exit 1; }
# --- 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"
kill -9 "$pid" 2>/dev/null || true
fi
done
sleep 1
ACCEL=()
WINDOWED=1
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
echo "desktop session detected — emulator window + GPU host"
else
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' …"
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
# --- 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
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 <<EOF
Ready. From any machine that can reach rutherford:
adb connect rutherford:5556
scrcpy -s rutherford:5556
If scrcpy says "unauthorized", click the RSA dialog on the emulator window.
EOF

BIN
smarttube.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB