The paused state was unusable (#4): the semi-transparent info overlay
covered the seek line, and the key handler kept owning CENTER/LEFT/RIGHT
(they toggled/seeked directly), so the transport buttons could never be
selected at all.
Replace it with a SmartTube-style pause screen: a big seek bar above an
info bar (never covered again — the panel below it is near-opaque) and
focusable Back 10 s / Play / Forward 10 s buttons. While paused the
arrows pick a control and OK activates it; the bar itself scrubs ±10 s
with arrows and OK starts playback. Focus moves into the overlay on
pause (Play button) and back to the plain key handler on resume, so
while playing, OK still toggles pause and LEFT/RIGHT still seek.
Refs #4
On the TV, DOWN from "Continue watching" often skipped the row below
and landed on "New from your subscriptions": the new-videos row is
rendered as a non-focusable "Loading…" while its items are still in
flight, and every FocusScrollRow auto-focused its first card when its
items arrived, so rows loaded out of order stole focus in a race.
Start fetching new local videos eagerly with the rest of the home data,
restrict the load-time auto-focus to the first row only, and cancel it
when the user has already pressed a D-pad key meanwhile. Also rename
the row to "New local videos" to distinguish it from the federated
subscription feed.
Refs #3
Jeena wanted subscriptions to behave exactly like My videos — a single
plain video list paged on scroll — instead of the SmartTube-style
per-channel rows, which felt noisy when many channels are subscribed.
The plain grid path already existed in VideoListScreen, so switch the
Subscriptions destination over to it and drop the rows screen.
Refs #5
BACK from a video dropped the user on a random spot (often the search
screen, #2): the restore token was only bumped when it was already >= 1,
so the very first back never re-focused anything, and HomeRowsViewModel
refresh() cleared the continue-watching rows synchronously, disposing
the rows before their focus-restore effect could run. Bump the token on
every playing -> not-playing transition and keep the rows alive during
a refresh instead.
Refs #2
Document how the host emulator is driven over adb from the agent
container: the start script now boots headless by default and trusts
remote adb keys via ADB_VENDOR_KEYS at launch, since the TV system
image is a user build that blocks pushing keys to adb_keys after boot.
AGENTS.md points to the new doc and fixes the stale SDK path.
Pressing HOME kept the audio running, so a video started in another
app played on top of it and returning landed on the home view with an
invisible player. The player now pauses itself on ON_PAUSE, reports
the position to the server and resumes automatically when the app
comes back. The playing screen is also kept across activity
recreation now, so returning shows the video, not the home view.
Ref #6
Replace the placeholder-content screenshots with a demo instance that
serves real CC-licensed open movies (Blender Foundation, framatube),
so the app looks like what it is: a video player with real thumbnails,
real durations and real playback.
On the HiMedia/Amlogic TV the video renders through the vendor video
plane, which scales the decoded stream to the whole panel and ignores
the letterbox geometry AspectRatioFrameLayout lays out for the
SurfaceView. Portrait videos therefore filled the screen instead of
being letterboxed; on emulators the same code was correct.
Render the video in a TextureView instead: it lives in the normal
view tree, so the aspect ratio is honored on every device. Verified
on the HiMedia box (vertical short letterboxed, landscape fills the
screen as before) and on the emulator.
Closes#1
Rewrite the README around what a user sees: what the app is for, how
the screens look and what to press. Add the logo, five screenshots
taken against a local mock PeerTube instance with placeholder content
(no private media), and a debug-only manifest that allows plain HTTP
so those mock instances work.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
Composable API mismatches with tv-material 1.0.1: NavigationDrawerItem
takes leadingContent/content (not icon/label), Card needs no shape.
Move VideoListItem to top level so its supertype reference resolves.
Pass onOpenVideo by name because the trailing lambda slot on
VideoListScreen goes to the non-functional viewModel parameter.
Nullable search param on local channel listings; make StateFlow
collection use collectAsState directly to avoid a JVM signature clash.
Verified by building app-debug.apk inside the container (JDK 21
installed there for this purpose).
Start the SmartTube-inspired PeerTube TV client as a Kotlin + Compose
for TV app. It targets any PeerTube instance (v1 API), with JTube as
the default, and covers login 0, local/own video listings 1-2, search
3, subscriptions 4, server-backed history 5 and HLS/progressive
playback with watching reports 6.
Password is only used for the OAuth password grant and never stored;
tokens are kept in EncryptedSharedPreferences. Building needs JDK 17+
(see README); the Gradle wrapper is checked in so no local Gradle is
required.