Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
9b96d03384 app: cap Coil's parallel fetch and decode work on TV hardware
A sched trace on the TV (release build) caught the real "2fps": not a
slow frame, but a starved one. During navigation the render pipeline
(app RenderThread, SurfaceFlinger/RenderEngine, the mali driver worker,
the Dolby audio service) saturated all four cores together with Coil's
image work — a burst of parallel ~30 ms bitmap decodes split across the
whole IO pool descheduled the main thread hundreds of times and froze
navigation for over a second.

The image loader now caps the request dispatcher at 2 parallel ops and
the decode/transformation pools at 2/1: images still fill in steadily
while scrolling but can no longer eat every core at once. Combined with
the release build on the TV (debug's JIT bursts produced 900 ms+ frames
by themselves), the same navigation settles at p90 ~13 ms per frame
with a worst frame of ~180 ms when a new row composes.
2026-10-20 08:00:13 +00:00
22d87d2398 channels: sort rows on stable keys only — subscriber counts shifted
everything mid-load

Making subscribers the primary key reordered both rows and whole rows
as the per-channel subscriber refine streamed in: each background batch
re-sorted the screen while loading, and the list jumped several times on
entry (user: "the jumping is terrible"). The subscriber counts are not
in the video-listing fast path; they only arrive seconds later in
per-channel batches, so any ordering that leads with them cannot render
early.

Back to the stable keys — own row first, then video totals (then plays,
then subscribers) on both the row and the channel level. These come
entirely from the video listing before the first rebuild, so the screen
painted correctly once, verified: a single rebuild line across the whole
TV load, no further reshuffling. Video-less channels still never render,
the own row still holds first place whenever it exists.
2026-10-19 14:40:26 +00:00
182650414c channels: hide video-less channels, sort users by subscribers first
Video-less channels dropped out of the row build when the fast path
switched to the video listing, and the previous commit wrongly merged
the own empty channels back in — the user rule is the opposite: a
channel (or a whole user row) with no videos renders at all; watching-
only accounts must stay invisible. The own row only exists when the
logged-in user owns at least one channel with videos.

The ordering is now one rule on both levels — user rows by each row's
channel sums, channels inside a row: subscribers, videos, views (desc).
Settled order on the TV verified against the instance's real follower
counts (kaylee 9, love 9, georg 6, yingfen 5, kinga 1 — georg's 211
videos only tie-break), with the own row first.
2026-10-19 14:28:11 +00:00
c0a7e94b14 channels: keep the logged-in user's row when it has no videos
The per-user rows are built from the local video listing, so a channel
that never uploaded is invisible to the row build — and an account that
owns only such channels lost its row entirely. tvbot is exactly that
case: the local tvbots channel exists, it has no videos, and while
logged in as tvbot the screen opened on the next user's channels.

The row build now merges the logged-in user's own local channels from
the account-channels listing (already fetched, carries the full card
data) into the locals before grouping; the group sort already puts the
own row first. Verified on the emulator logged in as tvbot: the
"tvbot's Channels" row renders first with the video-less tvbots card.
2026-10-19 14:09:27 +00:00
e7b15f819e emu: prefer the working host SDK over the broken home install
The emulator in ~/Android/Sdk auto-updated to 37.2.12 on Oct 11 and since
then consistently fails its own KVM probe ("/dev/kvm is not found") and
segfaults on that failure path — while KVM is healthy: a manual
KVM_GET_API_VERSION ioctl returns the current API level 12, and both
/opt/android-sdk and the container-home emulator pass -accel-check on
the same kernel and device node. The script now prefers the documented
host SDK at /opt/android-sdk when no ANDROID_SDK_ROOT is set.

Once the home SDK is repaired (or the emulator regression disappears),
the fallback order can be revisited.
2026-10-19 13:38:52 +00:00
35e0f31a74 emu: pin windowed mode to the integrated GPU
The discrete card now runs continuous AI inference, so the emulator must
not claim GL/Vulkan contexts on it. In windowed mode the emulator uses
the host GL/Vulkan stack, and the default GLVND selection picks the
discrete card on this machine. The script now discovers the first
display device whose driver is not nvidia and pins the emulator process
to it (Mesa GLX/EGL + DRI_PRIME + the Intel Vulkan ICD).

Headless (the default) needs no change: it always ran on swiftshader,
strictly software, and never touched any GPU.
2026-10-19 13:30:52 +00:00
0ab19b835c ui: cap the eased scroll and drop the snapshot focus map
A system trace on the TV showed the eased focus scroll re-ticking for
hundreds of milliseconds per move — animateScrollToItem's default spec
scales with distance, and every tick redraws the whole window on a TV
GPU, with 145 ms spikes when new cards composed mid-animation. That is
what drags row scrolling back down to a few fps, again (same symptom
51077b8 and 5ea1ca5 each fixed a facet of). easedScrollToItem keeps the
eased feel but caps the tween at ~140 ms, so a move costs a handful of
cheap ticks instead of hundreds.

The per-card focus requesters in the video grid were back in a snapshot
state map written during composition — the exact recompose loop
5ea1ca5 fixed in the rows, still alive in the grid screens. Plain
HashMap again, keyed on epoch only: keyed on items.size the map was
dropped wholesale on every page load, and already-composed cards didn't
re-populate it until their next recompose.
2026-10-19 13:08:18 +00:00
54dfe68726 images: rearm the loading shimmer only when the image changes
ShimmerAsyncImage keyed its loaded-state on the passed model, and the
callers built a fresh coil ImageRequest on every recomposition. ImageRequest
has no value equality, so the key changed every time: the loaded flag
reset, the shimmer overlay was re-added and the image load restarted with
each recomposition — an infinite animation per card that never disposed.
On the TV this showed as a general fps collapse wherever thumbnails
render (issue #28's feature shipped with this trap).

The requests are now remembered under stable keys (video/channel id and
the URL strings), so the sweep stops for real once the image (or an
error) lands.

Addresses #28.
2026-10-19 13:08:09 +00:00
f371a21e17 channel page: park the grid unconditionally, tighter header band
The old park ran only while `firstVisibleItemIndex > 0`, but the focus
pull that hands the first card its focus leaves the header row at index
0 while slid up by ~96 dp — so the guard never fired and the top of the
channel header stayed clipped off screen (#31 review). scrollToItem(0)
re-anchors both index and offset.

The banner band drops to 116 dp with a 40 dp description strip, so the
whole header is under one card row: the grid never reflows around it and
the header stays fully on screen when the first card takes focus.

Addresses #27, #31.
2026-10-19 13:08:00 +00:00
b9419e6a65 player: scroll the info text in thirds of a screen (#23)
One D-pad press moved 85% of the panel, so consecutive stops barely overlapped
and the middle of a long description was never on screen — on
tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN Jeena could not read the passage about
the credit-card outlier at all (#23 review). A press now moves about a third of
a screenful, so each stop repeats two thirds of the previous one and every line
gets shown on the way down.
2026-10-16 13:04:37 +00:00
45fd1b963e channels: never open the screen without a selection (#32)
Coming back from a channel page hands that channel's card the focus, but the
handle only wins when the card is still in the rows. A channel that was
renamed, unsubscribed, or simply never carried in the rows left every row
asking for no focus at all, so opening Channels from the drawer put the remote
nowhere: "when I click on Channels, none is selected, it should behave like
video cards" (#32 review). The fallback row is now chosen by whether the
handle exists anywhere in the rows, and a row whose wanted card never became
focusable takes its first card instead of staying unselected.
2026-10-16 13:03:28 +00:00
3854c6999a channel page: park the grid before the first card takes focus (#27, #31)
Two things were still wrong on the channel page and the list screens.

The header is a grid item again, so it scrolls with the videos (#27 review),
but handing the first video card the focus makes the framework pull that card
fully into view: the grid animates up, and the old park-at-top effect then
snapped it back down. Jeena still saw that jump "when the first video gets
focus". The park now runs before the focus handover, and only while the grid
is actually displaced, so there is nothing left to undo.

The first loading card on My videos, Search, Subscriptions, History and the
channel pages used to span the whole line, which read as a wide band across
four columns; Jeena wanted the plain single card in one column of one row
(#31 review), so the placeholder item no longer spans the line.
2026-10-16 13:03:04 +00:00
59d93974b5 player: match the normalised D-pad key when scrolling the description (#23)
Scrolling the info text up still did nothing on the TV while down worked. The
handler only matched the raw nativeKeyEvent keycode; remotes don't all report
the same codes, so match Compose's normalised Key.DirectionUp/Down as well, and
always attempt the scroll (animateScrollBy clamps at the ends itself) instead of
gating it on the current offset.

Not reproducible on the emulator in either the debug or the release build, so
this targets the device difference rather than a confirmed fault.
2026-10-15 14:56:00 +00:00
75a0153141 channels: load at launch, keep the opened channel selected (#32, #34)
Assembling the channel rows is slow, so it now starts when the app launches
instead of when the view is first opened; opening the view usually finds the
rows already there, and a second pass is skipped while one is in flight or the
data is still fresh (#32).

Coming back from a channel page re-selects the channel that was opened, scrolling
its row back to it, instead of snapping to the first one (#34).

Also drops the custom focusRestorer fallback added for #29: handing back a
requester whose card had scrolled out of composition crashed the app with
"FocusRequester is not initialized" when focus re-entered a scrolled row (hit on
the Channels screen). The plain restorer keeps the #29 behaviour without it.
2026-10-15 14:21:10 +00:00
d6fab03c47 channel: header scrolls with the videos again, reserved height, fallbacks (#27)
Review follow-ups on the channel page header:

- It is a grid item again instead of being pinned, so scrolling the videos moves
  it out of the way rather than leaving them to slide behind it. The redesigned
  header is compact enough to stay fully visible when the first card takes
  focus, and the page parks itself at the top on entry so the Subscribe button
  is on screen.
- Its height is reserved before the channel loads, so the cards no longer jump
  down when it arrives.
- A channel with no banner gets a gradient derived from a hash of its handle
  instead of flat black, and one with no icon gets a stand-in avatar.
2026-10-15 14:21:00 +00:00
d5040f8035 player: sidebar starts at the top, links stay in the text (#23, #24)
The info and comments panels sat under a hard-coded 48dp top padding, leaving a
black strip above them; they now fill the height from the very top (#24, and the
same note on #23).

The info panel no longer repeats the description's links in a list underneath —
they stay inline in the running text and OK opens the first one. UP/DOWN are
always consumed while scrolling, so the panel can no longer strand focus in a
place it can't scroll back from (#23 review).
2026-10-15 14:20:52 +00:00
e6792a98fd player: no spinner over the last frame when a video ends (#33)
STATE_ENDED counted as "not ready", so finishing a video brought the thumbnail
and the spinner back over the picture. Only STATE_BUFFERING drives the overlay
now; the initial load is already covered by the first-frame guard, so the video
simply stops on its last frame with the paused controls.
2026-10-15 14:20:44 +00:00
0ca17cfd00 loading: one loading card on the grids, not a full fake grid (#31)
My videos, Subscriptions, History and the channel pages painted two whole rows
of placeholder cards on first open, and Channels painted two stub rows. Show a
single loading card instead, matching the Home rows.
2026-10-15 14:20:35 +00:00
9f69cabf79 player: keep the thumbnail until the first frame, not just STATE_READY
ExoPlayer reports STATE_READY a beat before it actually draws the first frame,
so the buffering thumbnail was removed into a black gap right before playback
("thumbnail, then black, then playing"). Hold the thumbnail overlay until
onRenderedFirstFrame fires, so it hands straight over to the picture.
2026-10-15 13:46:26 +00:00
9ce367b601 player: show the card thumbnail as a poster while the video loads
Opening a video flashed plain black during the metadata-loading phase (before
buffering, where the thumbnail already shows) — most visible on the TV where
that phase is longer. Pass the opened card's thumbnail into the player and paint
it full-screen (dimmed, under the spinner) during loading, handing off to the
buffering thumbnail once the stream is ready, so the player opens onto the
video's own poster instead of black.
2026-10-15 13:33:12 +00:00
b78e7187c0 docs: regenerate screenshots via the mock backend, add the channel page (#30)
Recapture every screen through the branch-only mock backend
(tools/screenshot-mock) so the app runs unmodified against real CC/Blender
content: the rows no longer repeat thumbnails (Continue watching, New local
videos and My videos draw different slices), the account has an avatar, the
player sits on a frame with something happening, and the Subscribe button reads
correctly. Adds a channel-page screenshot (banner, stats, Subscribe, full video
grid) and references it in the README.
2026-10-15 13:07:33 +00:00
bdf9b35ebd docs: refresh the README screenshots (#30)
New screenshots of the current UI (title bar with the account on the right, the
redesigned Home with three rows and progress bars, My videos privacy badges, the
Channels rows and the player). Captured against the public PeerTube demo instance
(peertube2.cpy.re) so they show real CC/Blender content, as before.
2026-10-15 12:37:51 +00:00
a08f3b956c docs: rewrite the README for humans, add the PeerTube logo (#30)
Rewrite the README so it leads with what JTube TV is and who it's for, refresh
the feature list and screenshot captions to match the current UI (three Home
rows, Back opens the sidebar, Subscribe on the channel page), and add the
official PeerTube logo next to the name. Screenshot images are refreshed in a
follow-up.
2026-10-15 12:25:49 +00:00
f2d63fdb1c docs: remove BACKLOG.md
Future work is tracked as Forgejo issues now; the standalone backlog file was
redundant. AGENTS.md and README.md no longer reference it.
2026-10-15 12:25:25 +00:00
21d8290f1d channel: redesign the header, reachable Subscribe button (#27)
Without a mouse the channel banner was invisible: opening a channel focused the
first video card, which scrolled the header (a grid item) off-screen. The header
is now pinned above the grid and never scrolls away. The banner is its backdrop,
darkened to black at the bottom; the avatar sits beside the title with a
"by <owner> on <platform>" byline, the subscriber/video/created stats, and the
description flowing into the black beneath (a long one grows the header down
instead of being clipped). A Subscribe button sits on the banner, reachable with
UP from the first card, wired to the same follow/unfollow as the player.

AGENTS.md: subscribing is in scope (already on the player, now the channel page
too), so drop it from the excluded list.
2026-10-15 12:25:12 +00:00
b2af6fc1a5 images: shimmer placeholder while thumbnails and art load (#28)
Thumbnails, channel banners and avatars popped in from a flat grey rectangle. A
new ShimmerAsyncImage shows the same faint shimmer as the card placeholders
while the image loads, and the sweep disposes once the image (or an error)
arrives. Used for video thumbnails and the channel banners/avatars.
2026-10-15 12:25:12 +00:00
aea649eab9 loading: drop the snapshot cache, one loading card per thing (#25)
The saved-list snapshot (issue #9) painted the last session's rows instantly,
then the network refresh swapped them in and the tiles jumped as newer videos
appeared — jarring. Drop the snapshot instant-paint on Home, the video lists and
the Channels screen; images stay fast because Coil already disk-caches them.

Screens now show the loading card while the first page loads instead of stale
content: one card per Home row (not a fake full row), a single card at the end
of a growing grid, and a trailing card when a Home row loads its next page.
2026-10-15 12:25:12 +00:00
8eeffe834d rows: restore the last-focused card when moving between rows (#29)
Moving up/down between the Home rows (and the Channels rows) jumped to the
geometrically-nearest card, so the row shifted left/right and felt inconsistent.
A focusRestorer on each row now lands focus on the card that row last had, so
the rows stay put as you move between them.
2026-10-15 12:25:12 +00:00
349c1aa447 home: Back opens the sidebar from a list view (#26)
Reaching the sidebar used to mean scrolling all the way left to the first card.
Back from any list view now opens it directly; from the open sidebar on Home a
double Back still exits, and Esc (keyboard/scrcpy) mirrors the same model.
2026-10-15 12:25:12 +00:00
6307addfb8 player: title header + scrollable description in info/comments (#23, #24)
The info and comments side panels showed a generic "Info"/"Comments" label,
which read as an empty black bar with no clue which video it belonged to. Both
now show the video's title instead (#24, and the same complaint in #23).

The info panel's description also could not be scrolled with the D-pad: a plain
Text in a LazyColumn takes no focus, so a long description was unreadable past
the first screenful unless it happened to contain links. It is now a focusable,
D-pad-scrollable box; Down at the bottom hands focus to the link list, Left
closes the panel (#23).
2026-10-15 12:25:12 +00:00
20 changed files with 863 additions and 516 deletions

View file

@ -113,10 +113,11 @@ the workflow is strict:
## Scope discipline ## Scope discipline
v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
uploading, editing, subscriptions management, deletion or content creation — the user git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md` creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
first, then implement. in scope and already implemented — on the player and the channel page.) If the user asks
for a scope change, confirm it with them before implementing.
When unsure how a PeerTube endpoint behaves, verify against the live instance instead of When unsure how a PeerTube endpoint behaves, verify against the live instance instead of
guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route). guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route).

View file

@ -1,85 +0,0 @@
# Backlog
Status vocabulary: `todo` · `doing` · `done`.
## v0.1 — MVP (implemented, pending device sign-off)
| # | Item | Status | Notes |
|---|------|--------|-------|
| 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 |
| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon |
## Discussed but deferred (2026-10-12, while reworking the player controls)
- Like/dislike display: counts are in the video detail DTO; rating (own like via
`PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later.
- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration`
on the player; language selection in the controls overlay.
## 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 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.
- Video detail screen (description, tags, views, duration, channel page link).
## Longer term
- Multiple saved instances / switching between them.
- Live streams playback (HLS live), premieres.
- Subtitles/captions selection.
- Downloads for offline watching (probably limited to public videos).
- Comments viewing (read-only) — needs moderation-safe UI thinking.
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
- Out of scope by user request (do not implement without asking): uploading, editing,
creating channels/users. (Stale members of this list — subscribing, playlists
viewing/creation and rating — shipped with the player action row in v0.1; playlist
deletion was requested and added later, see below.)
- Delete own playlist: long-press OK on a playlist row (key-repeat) → confirmation
dialog → `DELETE /api/v1/video-playlists/{id}`; toggle stays on short press. Requested
2026-10-13.

View file

@ -4,71 +4,97 @@
<img src="docs/jtube-logo.svg" width="150" alt="The JTube TV logo, a little orange TV" /> <img src="docs/jtube-logo.svg" width="150" alt="The JTube TV logo, a little orange TV" />
</p> </p>
JTube TV is an Android TV app for [PeerTube](https://joinpeertube.org/). It puts <p align="center">
a PeerTube instance on the big screen the way SmartTube puts YouTube there: <em>Watch your PeerTube instance from the couch, with the remote.</em>
browse from the couch, play with the remote, nothing else in the way. </p>
It talks to any PeerTube instance over the JTube TV is an Android TV app for
<a href="https://joinpeertube.org/"><img src="docs/peertube-logo.svg" width="16" alt="" valign="middle" />&nbsp;PeerTube</a>.
It does for a PeerTube instance what SmartTube does for YouTube: a big-screen,
D-pad-first way to browse your videos, your channels and your watch history and
play them — and nothing else in the way.
It is built for people who self-host their videos on PeerTube and want to watch
them on the TV without a phone, a cast button or a browser. Sign in once with
your PeerTube account and the whole instance is a few clicks away on the remote:
your own uploads (private ones included), the channels you follow, what you were
watching last, and the instance's public videos and search.
It works with any PeerTube instance over the
[REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default [REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default
is [tube.jeena.net](https://tube.jeena.net), my own instance, where most videos in the sign-in screen is [tube.jeena.net](https://tube.jeena.net), the author's
are private family uploads. Signing in matters because "My videos" shows them own instance, but you can point it at yours.
all, privacy levels included.
## What it looks like ## What it looks like
Screenshots come from a demo instance serving real, CC-licensed open-movie The screenshots use a public demo instance with real, CC-licensed open-movie
content, courtesy of the [Blender Foundation](https://www.blender.org/) movies content, courtesy of the [Blender Foundation](https://www.blender.org/) movies
and the [A propos de PeerTube](https://framatube.org) channel. All of it plays and the [A propos de PeerTube](https://framatube.org) channel — all of it plays
in the app. in the app.
The home screen. Continue watching picks up at the position the server saved, **Home.** Three rows: *Continue watching* picks up each video at the position
new videos first, page after page. the server saved, then the newest local videos, then the newest from the
channels you follow. Scroll right and more load on their own.
<p align="center"> <p align="center">
<img src="docs/screenshots/home.png" width="800" alt="Home screen with Continue watching and New videos rows" /> <img src="docs/screenshots/home.png" width="800" alt="Home screen with Continue watching, New local videos and subscriptions rows" />
</p> </p>
A slide-in drawer opens with the left D-pad button. My videos shows your own **The menu.** A sidebar slides in — press the left D-pad at the edge of a row,
uploads, every privacy level included, with a badge on anything not public. or just press Back from anywhere in a list. It jumps to Home, My videos,
Channels, Search, Subscriptions and History.
<p align="center"> <p align="center">
<img src="docs/screenshots/drawer.png" width="800" alt="Menu drawer with the destinations" /> <img src="docs/screenshots/drawer.png" width="800" alt="Menu drawer with the destinations" />
</p> </p>
**My videos.** Everything you own, every privacy level, with a badge on
anything that isn't public.
<p align="center"> <p align="center">
<img src="docs/screenshots/myvideos.png" width="800" alt="My videos grid with Private and Internal badges" /> <img src="docs/screenshots/myvideos.png" width="800" alt="My videos grid with Private and Internal badges" />
</p> </p>
Channels, one row per owner, with video and play counts on every card. **Channels.** One row per owner, with video, subscriber and play counts on
every card.
<p align="center"> <p align="center">
<img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" /> <img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" />
</p> </p>
The player speaks remote: play and pause, seek back and forward, a seek bar Open a channel for its banner, its stats and all its videos — with a Subscribe
with position and duration. Watching is reported to the server every 20 button you can reach with the remote.
seconds, so where you left off survives the app.
<p align="center">
<img src="docs/screenshots/channel.png" width="800" alt="A channel page: banner, stats, Subscribe button and the channel's videos" />
</p>
**The player** speaks remote: play and pause, seek back and forward, a seek bar
with position and duration, and the video's info, comments and playlists a
button away. Watching is reported to the server every 20 seconds, so where you
left off survives closing the app.
<p align="center"> <p align="center">
<img src="docs/screenshots/player.png" width="800" alt="Player with controls, seek bar and title" /> <img src="docs/screenshots/player.png" width="800" alt="Player with controls, seek bar and title" />
</p> </p>
## What it does in v0.1 ## What it does
- Sign in once. The app exchanges your username and password for OAuth tokens - **Sign in once.** The app trades your username and password for OAuth tokens
and stores only those, encrypted with the Android Keystore. The password and keeps only those, encrypted with the Android Keystore. The password is
itself is never kept. never stored.
- Home with Continue watching and New videos. - **Home** with Continue watching, new local videos and new videos from your
- Browse the instance's public videos, and search them by title. subscriptions.
- My videos: every video you own, private, internal and unlisted included. - **Browse and search** the instance's public videos by title.
- Channels: local channels with their videos, plus your subscriptions and your - **My videos** — every video you own, private, internal and unlisted included.
server-side watch history. - **Channels** — local channels with their videos, plus your subscriptions and
- Player: HLS, with a progressive MP4 fallback, resume from the saved your server-side watch history.
position, and watching reports. - **Subscribe** to and unsubscribe from channels, from the player or a channel
page.
- **Player** — HLS with a progressive MP4 fallback, resume from the saved
position, like/dislike, comments, playlists, and watching reports.
What it deliberately does not do (yet, or ever): uploading, editing, What it deliberately does not do: uploading, editing or deleting content.
deleting, managing subscriptions. That's tracked in
[BACKLOG.md](BACKLOG.md).
## Build ## Build
@ -102,4 +128,6 @@ A single-module app, deliberately small:
## License ## License
JTube TV is free software under [GPL-3.0-or-later](LICENSE). JTube TV is free software under [GPL-3.0-or-later](LICENSE). The PeerTube logo
is a trademark of [Framasoft](https://framasoft.org/), used here to refer to the
PeerTube project.

View file

@ -1,6 +1,7 @@
package net.jeena.jtubetv package net.jeena.jtubetv
import android.app.Application import android.app.Application
import kotlinx.coroutines.Dispatchers
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
@ -33,9 +34,20 @@ class JTubeApp : Application(), coil.ImageLoaderFactory {
* TVs are slow compositors: decode thumbnails as RGB_565 at capped size so * 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"). * scrolling rows keep a smooth frame rate (user: "the animation has very low fps").
*/ */
override fun newImageLoader(): ImageLoader = override fun newImageLoader(): ImageLoader {
ImageLoader.Builder(this) // A sched trace on the TV (release build) showed the main thread being
// descheduled hundreds of times during navigation while the render
// pipeline (Mali/RenderThread/SF/Dolby) saturated all 4 cores together
// with Coil's image work — a burst of parallel 30 ms bitmap decodes
// starved the main thread for over a second (the felt "2fps"). Capping
// Coil's parallel work: images still fill in steadily while scrolling,
// but the decode/fetch pool can no longer eat every core at once.
return ImageLoader.Builder(this)
.bitmapConfig(android.graphics.Bitmap.Config.RGB_565) .bitmapConfig(android.graphics.Bitmap.Config.RGB_565)
.crossfade(false) .crossfade(false)
.dispatcher(Dispatchers.IO.limitedParallelism(2))
.decoderDispatcher(Dispatchers.Default.limitedParallelism(2))
.transformationDispatcher(Dispatchers.Default.limitedParallelism(1))
.build() .build()
}
} }

View file

@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.ui.list.Snapshots
/** Where the list's content comes from. */ /** Where the list's content comes from. */
sealed interface ListSource { sealed interface ListSource {
@ -56,16 +55,6 @@ class VideoListViewModel(
var videoTotal by androidx.compose.runtime.mutableStateOf(0L) var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
private set private set
// Snapshot caching (issue #9): live content lists only — Search results and
// History rows (which carry extra watch metadata) must not persist.
private val shouldSkipSnapshot =
source is ListSource.Search || source is ListSource.History
private fun cacheKey(): String = when (val s = source) {
is ListSource.Channel -> "channel-" + s.handle
else -> s::class.java.simpleName
}
init { init {
// Probe whether the instance allows the search-index ("Vidiverse") scope. // Probe whether the instance allows the search-index ("Vidiverse") scope.
if (source is ListSource.Search) { if (source is ListSource.Search) {
@ -74,24 +63,10 @@ class VideoListViewModel(
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
} }
} }
val context = AppGraph.appContext // No snapshot instant-paint any more (issue #25): the saved-list cache
if (context != null && !shouldSkipSnapshot) { // repainted stale rows that then jumped when the fresh page landed with
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { // newer videos. Screens now show the loading card until the first page
val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch // arrives; images stay fast via Coil's own disk cache.
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
showInstant(videos.map { VideoListItem.Video(it) })
}
// Channel page banner: restore the saved one instantly too.
val s = source
if (s is ListSource.Channel) {
Snapshots.loadChannelHeader(context, s.handle)?.let { header ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
channelHeader = header
}
}
}
}
}
} }
override fun onChunkLoaded(chunk: List<VideoListItem>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
@ -103,20 +78,6 @@ class VideoListViewModel(
} }
} }
) )
// Persist the freshly loaded content so the next app start paints
// instantly (issue #9).
if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) {
val context = AppGraph.appContext
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(
context,
cacheKey(),
items.filterIsInstance<VideoListItem.Video>().take(50).map { it.video },
)
}
}
}
} }
override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> { override suspend fun fetch(start: Long, count: Int): Result<List<VideoListItem>> {
@ -150,22 +111,9 @@ class VideoListViewModel(
fun loadChannelHeader(repository: PeerTubeRepository) { fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source val s = source
if (s is ListSource.Channel) { if (s is ListSource.Channel) {
// Instant restore from the snapshot, then a fresh fetch (issue #9).
val context = AppGraph.appContext
if (context != null) {
Snapshots.loadChannelHeader(context, s.handle)?.let { snap ->
if (channelHeader == null) channelHeader = snap
}
}
viewModelScope.launch { viewModelScope.launch {
repository.videoChannel(s.handle).getOrNull()?.let { header -> repository.videoChannel(s.handle).getOrNull()?.let { header ->
channelHeader = header channelHeader = header
val ctx = AppGraph.appContext
if (ctx != null) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelHeader(ctx, s.handle, header)
}
}
} }
} }
} }

View file

@ -4,6 +4,10 @@ import android.view.KeyEvent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -43,6 +47,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
@ -93,15 +99,16 @@ fun PlayerSidebar(
.width(SIDEBAR_WIDTH) .width(SIDEBAR_WIDTH)
.background(SidebarBg), .background(SidebarBg),
) { ) {
// Header = the video's title (issue #23/#24): the old generic "Info" /
// "Comments" label read as an empty black bar with no context about
// which video the panel belongs to. Playlists keeps its own label.
Text( Text(
text = when (mode) { text = if (mode == "playlists") "Playlists" else detail.name,
"playlists" -> "Playlists"
"info" -> "Info"
else -> "Comments"
},
color = Color.White, color = Color.White,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(20.dp), maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 10.dp),
) )
when (mode) { when (mode) {
"playlists" -> PlaylistsColumn(detail, onDismiss) "playlists" -> PlaylistsColumn(detail, onDismiss)
@ -161,19 +168,7 @@ private fun InfoColumn(
.distinct() .distinct()
.toList() .toList()
val firstLinkFocus = remember { FocusRequester() } val annotated = buildAnnotatedString {
LaunchedEffect(links) {
if (links.isNotEmpty()) {
kotlinx.coroutines.delay(150)
runCatching { firstLinkFocus.requestFocus() }
}
}
LazyColumn(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
item(key = "desc") {
Text(
text = buildAnnotatedString {
var rest = description var rest = description
while (true) { while (true) {
val match = UrlPattern.find(rest) ?: break val match = UrlPattern.find(rest) ?: break
@ -194,40 +189,107 @@ private fun InfoColumn(
rest = rest.substringAfter(match.value) rest = rest.substringAfter(match.value)
} }
append(rest) append(rest)
}, }
val descFocus = remember { FocusRequester() }
// The description owns the focus for the whole panel: UP/DOWN scroll it and are
// always consumed, so scrolling can never strand focus somewhere it can't come
// back from (issue #23 review: "it loses focus and it's impossible to scroll up").
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
runCatching { descFocus.requestFocus() }
}
Column(Modifier.fillMaxSize()) {
if (description.isBlank()) {
SidebarFallback("No description.", onDismiss)
} else {
DescriptionScroll(
annotated = annotated,
focusRequester = descFocus,
onDismiss = onDismiss,
// Links stay inline in the running text (issue #23 review) instead of
// being repeated in a list underneath; OK opens the first one.
onActivate = { links.firstOrNull()?.let { open(it) } },
modifier = Modifier.weight(1f, fill = false),
)
}
}
}
/**
* Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a
* LazyColumn can't be scrolled with the remote — nothing in it takes focus — so
* long descriptions were unreadable past the first screenful. This box takes
* focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands
* focus to the link list (if any).
*/
@Composable
private fun DescriptionScroll(
annotated: androidx.compose.ui.text.AnnotatedString,
focusRequester: FocusRequester,
onDismiss: () -> Unit,
onActivate: () -> Unit,
modifier: Modifier = Modifier,
) {
val scroll = androidx.compose.foundation.rememberScrollState()
val scope = rememberCoroutineScope()
var focused by remember { mutableStateOf(false) }
var viewportPx by remember { mutableStateOf(0) }
Box(
modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.focusRequester(focusRequester)
.focusable()
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
.onPreviewKeyEvent { ke ->
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// One press moves a third of a screenful, so consecutive stops
// overlap and every line of a long description gets shown (issue
// #23 review: the old 85% step jumped past the middle of the text
// on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN).
val step = (viewportPx * 0.34f).coerceAtLeast(150f)
val code = ke.nativeKeyEvent.keyCode
// Match Compose's normalised key AND the raw keycode: remotes don't all
// report the same native codes, and matching only the raw one left UP
// unhandled on some devices (issue #23 review).
val isUp = ke.key == Key.DirectionUp || code == KeyEvent.KEYCODE_DPAD_UP
val isDown = ke.key == Key.DirectionDown || code == KeyEvent.KEYCODE_DPAD_DOWN
val isLeft = ke.key == Key.DirectionLeft || code == KeyEvent.KEYCODE_DPAD_LEFT
val isCenter = ke.key == Key.DirectionCenter || ke.key == Key.Enter ||
code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER
when {
// UP/DOWN are always consumed and always attempt the scroll (it
// clamps at the ends by itself), so focus stays here and the text
// can always be scrolled back up.
isDown -> {
scope.launch { scroll.animateScrollBy(step) }
true
}
isUp -> {
scope.launch { scroll.animateScrollBy(-step) }
true
}
isLeft -> {
onDismiss(); true
}
isCenter -> {
onActivate(); true
}
else -> false
}
}
.verticalScroll(scroll)
.onSizeChanged { viewportPx = it.height },
) {
Text(
text = annotated,
color = TextMain, color = TextMain,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
) )
} }
}
if (links.isNotEmpty()) {
item(key = "linkshdr") {
Text(
text = "Links",
color = TextDim,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
)
}
links.forEachIndexed { index, url ->
item(key = "l" + index + url) {
SidebarRow(
onLeft = onDismiss,
focusRequester = if (index == 0) firstLinkFocus else null,
onClick = { open(url) },
) {
Text(
text = linkLabel(url),
color = TextMain,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
}
}
}
}
} }
/** Link click listener: the annotation callback shape. */ /** Link click listener: the annotation callback shape. */

View file

@ -2,6 +2,8 @@ package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -37,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
@ -63,19 +66,18 @@ import net.jeena.jtubetv.data.api.ChannelStats
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoChannelDto
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.list.ImagePrefetcher
import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.ContainerShape
import net.jeena.jtubetv.ui.screens.list.ShimmerAsyncImage
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
/** /**
* Channels, SmartTube-style: one row per LOCAL user. * Channels, SmartTube-style: one row per LOCAL user.
* *
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local * - Row 1: the logged-in user ("<display name>'s Channels") — only when they own
* users sorted by videos / subscribers / plays (desc). * at least one channel WITH videos; then the other local users by total videos.
* - Each row: that user's channels, same ordering; channels with no videos simply * - Channels with no videos never render (watching-only accounts), at any level.
* stay inside their user's row.
* - Followed-instance channels at the end, grouped per platform. * - Followed-instance channels at the end, grouped per platform.
* *
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
@ -93,10 +95,6 @@ data class UserGroup(
class ChannelsViewModel(private val store: TokenStore) : ViewModel() { class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
private val snapshotContext = AppGraph.appContext
private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_")
private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) }
var user by mutableStateOf<UserDto?>(null) var user by mutableStateOf<UserDto?>(null)
private set private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList()) var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
@ -126,26 +124,21 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
/** SystemClock at the last successful fast-path pass — re-entry freshness. */ /** SystemClock at the last successful fast-path pass — re-entry freshness. */
private var lastLoadedAt = 0L private var lastLoadedAt = 0L
init { // No snapshot instant-paint any more (issue #25): the Channels screen shows
// Snapshot restore (issue #9): paint the last session's channel rows // its shimmer placeholder rows until the (fast-path, few-request) load lands,
// instantly, including the per-channel stats (the "minutes of loading" // instead of painting a stale snapshot that reshuffled as stats streamed in.
// complaint) — the streaming reload below then updates them in place.
if (snapshotRows != null) {
userRows = snapshotRows
loading = false
}
if (snapshotContext != null) {
statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey))
if (statsMap.isNotEmpty()) stats = statsMap.toMap()
}
}
/** Guards against a second pass while the first is still running (issue #32:
* the load now starts at app launch, so opening the view can overlap it). */
private var inFlight = false
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
// Re-entry (destination switch away + back) inside the freshness window: // Re-entry (destination switch away + back) inside the freshness window:
// repaint the in-memory rows and skip the network (issue #13) — rows are // repaint the in-memory rows and skip the network (issue #13) — rows are
// never torn down on re-entry; an account switch builds a new ViewModel. // never torn down on re-entry; an account switch builds a new ViewModel.
if (inFlight) return
if (!force && userRows.isNotEmpty() && fresh()) return if (!force && userRows.isNotEmpty() && fresh()) return
inFlight = true
loading = true loading = true
error = null error = null
viewModelScope.launch { viewModelScope.launch {
@ -174,6 +167,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {
loading = false loading = false
inFlight = false
} }
} }
} }
@ -224,11 +218,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
stats = statsMap.toMap() stats = statsMap.toMap()
rebuildRows(host) rebuildRows(host)
lastLoadedAt = android.os.SystemClock.elapsedRealtime() lastLoadedAt = android.os.SystemClock.elapsedRealtime()
snapshotContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
}
}
ImagePrefetcher.channelArt(allChannels + userChannels) ImagePrefetcher.channelArt(allChannels + userChannels)
} }
@ -280,14 +269,24 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
} }
private fun rebuildRowsInner(host: String?) { private fun rebuildRowsInner(host: String?) {
val locals = allChannels.filter { it.host == host } // Channels without videos don't exist on this screen: plenty of local
// accounts are watching-only and their empty channels are pure noise if
// rendered (user rule). This also covers the own row — it only renders
// when the logged-in user has at least one channel with videos, and the
// group sort below still puts it first in that case.
val locals = allChannels
.filter { it.host == host && statOf(it) > 0 }
val ownNames = userChannels.map { c -> c.name }.toSet() val ownNames = userChannels.map { c -> c.name }.toSet()
val byOwner = locals.groupBy { ownerKey(it) } val byOwner = locals.groupBy { ownerKey(it) }
val groups = byOwner.values.map { row -> val groups = byOwner.values.map { row ->
val sorted = row.sortedWith( val sorted = row.sortedWith(
// Stable keys only: all of these come from the video listing and
// never change mid-load — subscriber counts arrive later per-
// channel and must not reorder anything while loading (rows that
// jump while stats stream in read as broken).
compareByDescending<VideoChannelDto> { statOf(it) } compareByDescending<VideoChannelDto> { statOf(it) }
.thenByDescending { it.followersCount } .thenByDescending { playsOf(it) }
.thenByDescending { playsOf(it) }, .thenByDescending { it.followersCount },
) )
val own = row.firstOrNull { it.name in ownNames } val own = row.firstOrNull { it.name in ownNames }
UserGroup( UserGroup(
@ -300,14 +299,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
userRows = groups.sortedWith( userRows = groups.sortedWith(
compareByDescending<UserGroup> { g -> g.channels.any { it.name in ownNames } } 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 { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.plays ?: 0 } }
.thenByDescending { g -> g.channels.sumOf { it.followersCount } }, .thenByDescending { g -> g.channels.sumOf { it.followersCount } },
) )
// Persist for the next cold start (issue #9).
snapshotContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveUserGroups(ctx, snapshotKey, userRows)
}
}
Log.w( Log.w(
"JTube", "JTube",
"order now: " + userRows.take(6).joinToString(" | ") { g -> "order now: " + userRows.take(6).joinToString(" | ") { g ->
@ -335,6 +329,8 @@ fun ChannelsScreen(
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0, drawerPulse: Int = 0,
/** Channel opened last — gets the focus back on return (issue #34). */
focusHandle: String? = null,
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory), viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
) { ) {
LaunchedEffect(epoch) { viewModel.load() } LaunchedEffect(epoch) { viewModel.load() }
@ -342,10 +338,8 @@ fun ChannelsScreen(
when { when {
viewModel.loading && viewModel.userRows.isEmpty() -> viewModel.loading && viewModel.userRows.isEmpty() ->
// Placeholder rows while the channels load (issue #19): header stub // One row header stub + a single loading card, not a fake grid (#31).
// + a row of shimmer card-shaped placeholders, twice.
androidx.compose.foundation.layout.Column { androidx.compose.foundation.layout.Column {
repeat(2) {
Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) { Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
Box( Box(
Modifier Modifier
@ -355,8 +349,7 @@ fun ChannelsScreen(
.background(Color(0xFF232323)), .background(Color(0xFF232323)),
) )
} }
ShimmerRow() ShimmerRow(size = 1)
}
} }
viewModel.error != null && viewModel.userRows.isEmpty() -> viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -377,6 +370,14 @@ fun ChannelsScreen(
// as stats stream in the rows re-sort, and re-focusing their first card // 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"). // would steal focus mid-navigation (user: "up jumps to the first row").
val focusHandedOut = remember { mutableStateOf(false) } val focusHandedOut = remember { mutableStateOf(false) }
// The channel we came back from only wins the focus while it is still
// in the rows. When it isn't (renamed, unsubscribed, or the rows simply
// never carried it) every row used to ask for no focus at all — "when I
// click on Channels, none is selected" (#32 review). Fall back to the
// first row in that case, like the video cards do.
val focusRowExists = focusHandle != null && viewModel.userRows.any {
group -> group.channels.any { it.name + "@" + it.host == focusHandle }
}
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
viewModel.userRows.forEach { group -> viewModel.userRows.forEach { group ->
item(key = "hdr-" + group.ownerName) { item(key = "hdr-" + group.ownerName) {
@ -386,13 +387,22 @@ fun ChannelsScreen(
) )
} }
item(key = "row-" + group.ownerName) { item(key = "row-" + group.ownerName) {
// Coming back from a channel page, focus the card we opened
// instead of the very first one (issue #34).
val target = group.channels.indexOfFirst {
it.name + "@" + it.host == focusHandle
}
val isTargetRow = target >= 0
ChannelRow( ChannelRow(
group.channels, group.channels,
onOpenChannel, onOpenChannel,
onOpenDrawer, onOpenDrawer,
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
focused = !focusHandedOut.value && focused = !focusHandedOut.value && (
group == viewModel.userRows.firstOrNull(), if (focusRowExists) isTargetRow
else group == viewModel.userRows.firstOrNull()
),
focusIndex = if (isTargetRow) target else 0,
onFocused = { focusHandedOut.value = true }, onFocused = { focusHandedOut.value = true },
stats = { viewModel.stats[it.name + "@" + it.host] }, stats = { viewModel.stats[it.name + "@" + it.host] },
platformName = { viewModel.platformNames[it.host ?: ""] }, platformName = { viewModel.platformNames[it.host ?: ""] },
@ -439,6 +449,7 @@ private fun absoluteAvatar(avatar: Avatar): String =
} }
/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ /** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable @Composable
private fun ChannelRow( private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
@ -446,6 +457,8 @@ private fun ChannelRow(
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
drawerPulse: Int = 0, drawerPulse: Int = 0,
focused: Boolean, focused: Boolean,
/** Which card to hand the initial focus to (issue #34). */
focusIndex: Int = 0,
onFocused: () -> Unit = {}, onFocused: () -> Unit = {},
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformName: (VideoChannelDto) -> String?, platformName: (VideoChannelDto) -> String?,
@ -468,19 +481,39 @@ private fun ChannelRow(
val notFullyVisible = target == null || val notFullyVisible = target == null ||
target.offset < 0 || target.offset < 0 ||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset target.offset + target.size > rowState.layoutInfo.viewportEndOffset
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
} }
} }
if (focused) { if (focused) {
LaunchedEffect(channels.isNotEmpty()) { LaunchedEffect(channels.isNotEmpty(), focusIndex) {
if (channels.isNotEmpty()) { if (channels.isNotEmpty()) {
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() val target = focusIndex.coerceIn(0, channels.size - 1)
// Bring it on screen first; a card further right isn't composed yet,
// so retry until its focus requester is attached (issue #34).
if (target > 0) runCatching { rowState.scrollToItem(target) }
repeat(8) {
delay(80)
val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull()
if (ok != null) {
onFocused()
return@LaunchedEffect
}
}
// The wanted card never became focusable (its row scrolled past it,
// or the handle went stale): take the first card instead, so the
// screen is never left with nothing selected (#32 review).
val first = focusRequesters[0]
if (first != null && runCatching { first.requestFocus() }.isSuccess) onFocused()
} }
} }
} }
LazyRow( LazyRow(
state = rowState, state = rowState,
// Land on the last-focused card when moving between rows (issue #29). No
// custom fallback: a requester whose card has scrolled out of composition
// crashed with "FocusRequester is not initialized".
modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@ -553,13 +586,19 @@ fun ChannelCard(
val coverUrl = banner?.let { absoluteBanner(it) } val coverUrl = banner?.let { absoluteBanner(it) }
?: stats?.latestThumbUrl ?: stats?.latestThumbUrl
if (coverUrl != null) { if (coverUrl != null) {
AsyncImage( // The request must be remembered: ImageRequest has no value equality,
model = coil.request.ImageRequest.Builder( // a fresh request per recomposition reset the shimmer (and retried
androidx.compose.ui.platform.LocalContext.current, // the load) every frame — the fps regression on the TV.
) val ctx = androidx.compose.ui.platform.LocalContext.current
val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host }
val coverReq = remember(coverKey, coverUrl) {
coil.request.ImageRequest.Builder(ctx)
.data(coverUrl) .data(coverUrl)
.size(480, 270) // same key as prefetch .size(480, 270) // same key as prefetch
.build(), .build()
}
ShimmerAsyncImage(
model = coverReq,
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
@ -567,21 +606,26 @@ fun ChannelCard(
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
AsyncImage( val ctx = androidx.compose.ui.platform.LocalContext.current
model = coil.request.ImageRequest.Builder( val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
androidx.compose.ui.platform.LocalContext.current, val avatarReq = remember(avatarUrl) {
coil.request.ImageRequest.Builder(
ctx,
) )
.data(absoluteAvatar(avatar)) .data(avatarUrl)
.size(80, 80) .size(80, 80)
.build(), .build()
}
ShimmerAsyncImage(
model = avatarReq,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(8.dp) .padding(8.dp)
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape),
.background(Color(0xFF333333)),
) )
} }
} }

View file

@ -1,5 +1,7 @@
package net.jeena.jtubetv.ui.screens package net.jeena.jtubetv.ui.screens
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -9,11 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -40,7 +44,6 @@ import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel import net.jeena.jtubetv.ui.list.PagedListViewModel
import net.jeena.jtubetv.ui.list.Snapshots
import net.jeena.jtubetv.ui.screens.list.ShimmerCard import net.jeena.jtubetv.ui.screens.list.ShimmerCard
import net.jeena.jtubetv.ui.screens.list.VideoCard import net.jeena.jtubetv.ui.screens.list.VideoCard
@ -57,8 +60,6 @@ data class ContinueItem(val video: VideoDto, val progress: Float?)
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
private fun contextOrNull() = AppGraph.appContext
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList()) var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
private set private set
var continueLoading by mutableStateOf(true) var continueLoading by mutableStateOf(true)
@ -75,36 +76,24 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
private var continueInFlight = false private var continueInFlight = false
var continueExhausted by mutableStateOf(false) var continueExhausted by mutableStateOf(false)
private set private set
/** True while the next continue page is loading — drives a trailing loading card (#25). */
var continueLoadingMore by mutableStateOf(false)
private set
private var subsStart = 0L private var subsStart = 0L
private var subsInFlight = false private var subsInFlight = false
var subsExhausted by mutableStateOf(false) var subsExhausted by mutableStateOf(false)
private set private set
var subsLoadingMore by mutableStateOf(false)
private set
/** Exposed for the shimmer placeholders (issue #19). */ /** Exposed for the shimmer placeholders (issue #19). */
var subsLoading = false var subsLoading = false
private set private set
val recent = RecentVideosViewModel(repository) val recent = RecentVideosViewModel(repository)
init { // No snapshot instant-paint any more (issue #25): Home shows one loading card
// Snapshot restore (issue #9): Home paints from the last session's rows // per row until the network pages land, instead of painting a stale snapshot
// immediately; the loads below freshen them from the network. // that then jumped as newer videos arrived.
val context = contextOrNull()
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val savedContinue = Snapshots.loadContinue(context, "home-continue")
val savedSubs = Snapshots.loadVideos(context, "home-subs")
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
if (continueItems.isEmpty() && savedContinue != null) {
continueItems = savedContinue
continueLoading = false
}
if (subItems.isEmpty() && savedSubs != null) {
subItems = savedSubs
}
}
}
}
}
private fun fetchSubs() { private fun fetchSubs() {
viewModelScope.launch { viewModelScope.launch {
@ -115,12 +104,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
subItems = subs subItems = subs
subsLoading = false subsLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
// Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(ctx, "home-subs", subs)
}
}
} }
} }
@ -176,6 +159,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun continueMore() { fun continueMore() {
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
continueInFlight = true continueInFlight = true
continueLoadingMore = true
viewModelScope.launch { viewModelScope.launch {
val page = fetchContinuePage() val page = fetchContinuePage()
if (page.isNotEmpty()) { if (page.isNotEmpty()) {
@ -183,6 +167,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video }) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
} }
continueInFlight = false continueInFlight = false
continueLoadingMore = false
} }
} }
@ -190,6 +175,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
fun subsMore() { fun subsMore() {
if (subsInFlight || subsExhausted || subItems.isEmpty()) return if (subsInFlight || subsExhausted || subItems.isEmpty()) return
subsInFlight = true subsInFlight = true
subsLoadingMore = true
viewModelScope.launch { viewModelScope.launch {
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList() val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
subsStart += page.size subsStart += page.size
@ -199,6 +185,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
} }
subsInFlight = false subsInFlight = false
subsLoadingMore = false
} }
} }
@ -210,12 +197,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
continueItems = items continueItems = items
continueLoading = false continueLoading = false
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
// Snapshot for the next cold start (issue #9).
contextOrNull()?.let { ctx ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveContinue(ctx, "home-continue", items)
}
}
} }
} }
@ -235,28 +216,8 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
/** Paged "New videos" row content. */ /** Paged "New videos" row content. */
class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() { class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel<VideoDto>() {
// Instant restore from the last session (issue #9); showInstant() refreshes
// the first page in the background.
init {
val context = AppGraph.appContext
if (context != null) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.loadVideos(context, "RecentVideos")?.let { snapshot ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
showInstant(snapshot)
}
}
}
}
}
override fun onChunkLoaded(chunk: List<VideoDto>) { override fun onChunkLoaded(chunk: List<VideoDto>) {
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk) net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(chunk)
AppGraph.appContext?.let { ctx ->
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
Snapshots.saveVideos(ctx, "RecentVideos", items.take(50))
}
}
} }
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> = override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
@ -314,9 +275,10 @@ fun HomeRowsScreen(
} }
item { item {
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) { if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
// Dummy cards with the faint shimmer while the row loads (#19); // One placeholder card per row while it loads (#25): a single card
// reads as "this row is coming", not a fake full row of content.
// "Watch something first" only when there is genuinely no history. // "Watch something first" only when there is genuinely no history.
ShimmerRow() ShimmerRow(size = 1)
} else if (viewModel.continueItems.isEmpty()) { } else if (viewModel.continueItems.isEmpty()) {
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
Text( Text(
@ -338,6 +300,7 @@ fun HomeRowsScreen(
autoFocusOnLoad = true, autoFocusOnLoad = true,
autoFocusAllowed = { !userNavigated }, autoFocusAllowed = { !userNavigated },
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.continueLoadingMore,
) { index, video -> ) { index, video ->
// Grow the row on demand (issue #18), same hook as the recent row. // Grow the row on demand (issue #18), same hook as the recent row.
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
@ -357,7 +320,7 @@ fun HomeRowsScreen(
item { item {
if (recentItems.isEmpty()) { if (recentItems.isEmpty()) {
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
ShimmerRow() ShimmerRow(size = 1)
} else { } else {
FocusScrollRow( FocusScrollRow(
videos = recentItems, videos = recentItems,
@ -367,6 +330,7 @@ fun HomeRowsScreen(
clickedIndex = recentRestore, clickedIndex = recentRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.recent.loadingMore,
) { index, video -> ) { index, video ->
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.recent.loadMore() } LaunchedEffect(index) { viewModel.recent.loadMore() }
@ -385,7 +349,7 @@ fun HomeRowsScreen(
} }
item { item {
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
ShimmerRow() ShimmerRow(size = 1)
} else { } else {
FocusScrollRow( FocusScrollRow(
videos = viewModel.subItems, videos = viewModel.subItems,
@ -395,6 +359,7 @@ fun HomeRowsScreen(
clickedIndex = subRestore, clickedIndex = subRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
refocusPulse = drawerPulse, refocusPulse = drawerPulse,
trailingLoading = viewModel.subsLoadingMore,
) { index, video -> ) { index, video ->
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) { if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
LaunchedEffect(index) { viewModel.subsMore() } LaunchedEffect(index) { viewModel.subsMore() }
@ -424,6 +389,7 @@ fun ShimmerRow(size: Int = 4) {
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of * viewport, the row animates it to the left edge (SmartTube behaviour) instead of
* the abrupt jump the built-in lazy scroll produces. * the abrupt jump the built-in lazy scroll produces.
*/ */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable @Composable
fun FocusScrollRow( fun FocusScrollRow(
videos: List<VideoDto>, videos: List<VideoDto>,
@ -442,6 +408,9 @@ fun FocusScrollRow(
// NOTE: stays before [afterItem] — the trailing lambda binds to the last // NOTE: stays before [afterItem] — the trailing lambda binds to the last
// declared parameter. // declared parameter.
refocusPulse: Int = 0, refocusPulse: Int = 0,
// A trailing loading card at the row's end while the next page loads (#25),
// so it's clear more is coming when scrolling right.
trailingLoading: Boolean = false,
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
@ -484,11 +453,17 @@ fun FocusScrollRow(
val notFullyVisible = target == null || val notFullyVisible = target == null ||
target.offset < 0 || target.offset < 0 ||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
} }
} }
LazyRow( LazyRow(
state = rowState, state = rowState,
// Jumping between rows lands on the card this row last had focus on, not
// the geometrically-nearest one (issue #29): the rows stop shifting
// left/right as you move up and down between them. No custom fallback —
// handing back a requester whose card has scrolled out of composition
// crashed with "FocusRequester is not initialized".
modifier = Modifier.focusRestorer(),
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@ -512,5 +487,30 @@ fun FocusScrollRow(
) )
afterItem(index, video) afterItem(index, video)
} }
if (trailingLoading) {
item(key = "trailing-loading") {
ShimmerCard(
modifier = Modifier
.padding(horizontal = 4.dp)
.width(rowCardWidth()),
)
}
}
} }
} }
/**
* Eased scroll to [index] with a capped duration (perf): [animateScrollToItem]'s
* default spec keeps re-ticking for hundreds of ms on long distances, and every
* tick is a full-window redraw on a TV GPU — which is what dragged the scroll
* down to a few fps. A short tween keeps the eased feel at a fraction of the
* ticks. (Shared by the home rows and the channels rows.)
*/
suspend fun LazyListState.easedScrollToItem(index: Int) {
val info = layoutInfo
val first = info.visibleItemsInfo.firstOrNull() ?: return
val stride = first.size + info.mainAxisItemSpacing
if (stride <= 0) return
val delta = index * stride - (first.index * stride + firstVisibleItemScrollOffset)
animateScrollBy(delta.toFloat(), tween(140))
}

View file

@ -87,10 +87,22 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
var destination by rememberSaveable { mutableStateOf(Destination.Home) } var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
// Last channel opened from the Channels screen — refocused on return (#34).
var lastOpenedChannel by rememberSaveable { mutableStateOf<String?>(null) }
// Channels are expensive to assemble, so start that work at app launch rather
// than when the view is first opened (issue #32); the view then usually finds
// the rows already there, and load() no-ops while fresh.
val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
key = "channels$epoch",
factory = ChannelsViewModel.factory,
)
LaunchedEffect(epoch) { channelsViewModel.load() }
// NOT saveable: launching the app should never resume a half-watched player session. // NOT saveable: launching the app should never resume a half-watched player session.
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
// Non-null while the playing video lives on ANOTHER instance (vidiverse results). // Non-null while the playing video lives on ANOTHER instance (vidiverse results).
var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) } var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) }
// Thumbnail of the opened card — shown as the player's poster while it loads.
var playingVideoThumb by rememberSaveable { mutableStateOf<String?>(null) }
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
var drawerOpen by rememberSaveable { mutableStateOf(false) } var drawerOpen by rememberSaveable { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@ -114,19 +126,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
} }
} }
// BACK order (TV standard): player (own handler) → drawer close → channel // BACK order (issue #26): the sidebar is the root of a list view, reached
// drilldown → non-Home destination → Home; from Home: double-press exits. // with one BACK press instead of scrolling all the way left.
// player → its own handler
// drawer open, Home → double-press exits (the sidebar is the root)
// drawer open, else → close the drawer
// channel drilldown → back up to the list that opened it
// any list view → open the sidebar
BackHandler(enabled = drawerOpen && playingVideo == null) { BackHandler(enabled = drawerOpen && playingVideo == null) {
dismissDrawer() if (destination == Destination.Home && channelDetails == null) {
}
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() val now = System.currentTimeMillis()
if (now - lastBackAt < 2500) { if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish() (context as? android.app.Activity)?.finish()
@ -134,8 +142,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
} }
lastBackAt = now lastBackAt = now
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show()
} else {
dismissDrawer()
} }
} }
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
channelDetails = null
}
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) {
drawerOpen = true
}
Box( Box(
modifier = Modifier modifier = Modifier
@ -151,7 +167,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
when { when {
drawerOpen -> { dismissDrawer(); true } drawerOpen -> { dismissDrawer(); true }
channelDetails != null -> { channelDetails = null; true } channelDetails != null -> { channelDetails = null; true }
else -> false // Mirror the D-pad BACK model (issue #26): Esc opens the
// sidebar from a top-level list view too.
else -> { drawerOpen = true; true }
} }
} else false } else false
}, },
@ -169,6 +187,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/') val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
val host = video.channel.host val host = video.channel.host
playingVideoHost = if (host != null && host != localHost) host else null playingVideoHost = if (host != null && host != localHost) host else null
playingVideoThumb = video.thumbnailUrl
?: video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { AppGraph.repository.absoluteUrl(it) }
playingVideo = video.shortUUID playingVideo = video.shortUUID
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@ -210,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle ->
channelDetails = handle
lastOpenedChannel = handle
},
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
drawerPulse = drawerPulse, drawerPulse = drawerPulse,
// Back from a channel page re-selects it (issue #34).
focusHandle = lastOpenedChannel,
viewModel = channelsViewModel,
) )
Destination.Search -> Destination.Search ->
VideoListScreen( VideoListScreen(
@ -267,13 +293,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
PlayerScreen( PlayerScreen(
shortUuid = playingVideo!!, shortUuid = playingVideo!!,
sourceHost = playingVideoHost, sourceHost = playingVideoHost,
posterUrl = playingVideoThumb,
onBack = { playingVideo = null }, onBack = { playingVideo = null },
onOpenChannel = { handle, displayName -> onOpenChannel = { handle, displayName ->
playingVideo = null playingVideo = null
channelDetails = handle channelDetails = handle
channelName = displayName channelName = displayName
}, },
onOpenVideo = { playingVideo = it; playingVideoHost = null }, // A link-opened video has no card thumbnail to pose with.
onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null },
) )
} }
} }

View file

@ -207,6 +207,9 @@ fun PlayerScreen(
onBack: () -> Unit, onBack: () -> Unit,
/** Set when the video lives on another instance (vidiverse search results). */ /** Set when the video lives on another instance (vidiverse search results). */
sourceHost: String? = null, sourceHost: String? = null,
/** Thumbnail of the opened card, shown full-screen while the metadata loads so
* the player doesn't flash black before buffering starts (user report). */
posterUrl: String? = null,
// From the info sidebar's local PeerTube video links: switches the player over. // From the info sidebar's local PeerTube video links: switches the player over.
onOpenVideo: (String) -> Unit = {}, onOpenVideo: (String) -> Unit = {},
// From the paused/controls overlay's channel icon: (handle, displayName), // From the paused/controls overlay's channel icon: (handle, displayName),
@ -227,15 +230,27 @@ fun PlayerScreen(
} }
when (val s = state) { when (val s = state) {
is PlayerUiState.Loading -> PlayerLoading() is PlayerUiState.Loading -> PlayerLoading(posterUrl)
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack) is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo) is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
} }
} }
@Composable @Composable
private fun PlayerLoading() { private fun PlayerLoading(posterUrl: String? = null) {
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
// Show the card's thumbnail (dimmed) while the metadata resolves, so the
// player opens onto the video's own poster instead of plain black; it
// hands off seamlessly to the buffering thumbnail once the stream is ready.
if (posterUrl != null) {
coil.compose.AsyncImage(
model = posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(Modifier.fillMaxSize().background(Color(0xB3000000)))
}
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
@ -308,6 +323,10 @@ private fun PlayerSurface(
// 200 ms: quick flaps cancel the pending change and the overlay stays // 200 ms: quick flaps cancel the pending change and the overlay stays
// composed, so the animation never restarts mid-playback. // composed, so the animation never restarts mid-playback.
var bufferingPending by remember { mutableStateOf(true) } var bufferingPending by remember { mutableStateOf(true) }
// STATE_READY can fire before the first frame is actually drawn, leaving a black
// gap between the thumbnail and the picture. Keep the thumbnail until the first
// frame really renders (user report: "thumbnail, then black, then playing").
var firstFrame by remember { mutableStateOf(false) }
LaunchedEffect(bufferingPending) { LaunchedEffect(bufferingPending) {
kotlinx.coroutines.delay(200) kotlinx.coroutines.delay(200)
buffering = bufferingPending buffering = bufferingPending
@ -419,7 +438,14 @@ private fun PlayerSurface(
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
bufferingPending = playbackState != Player.STATE_READY // Only real buffering shows the overlay (issue #33): STATE_ENDED used
// to count as "not ready" and brought the thumbnail + spinner back
// over the last frame. The initial load is covered by !firstFrame.
bufferingPending = playbackState == Player.STATE_BUFFERING
}
override fun onRenderedFirstFrame() {
firstFrame = true
} }
}) })
onDispose { onDispose {
@ -573,7 +599,7 @@ private fun PlayerSurface(
// ready to render — metadata resolution alone (the Loading state) is not // ready to render — metadata resolution alone (the Loading state) is not
// enough (issue #8). Fullscreen loading shows the video's own thumbnail // enough (issue #8). Fullscreen loading shows the video's own thumbnail
// dimmed under the circle loader (issue #19) instead of plain black. // dimmed under the circle loader (issue #19) instead of plain black.
if (buffering) { if (buffering || !firstFrame) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -620,9 +646,9 @@ private fun PlayerSurface(
closeSidebar() closeSidebar()
}, },
onOpenVideo = onOpenVideo, onOpenVideo = onOpenVideo,
modifier = Modifier // Full height from the very top — the old top padding left a black
.align(Alignment.CenterEnd) // strip above the panel (issues #23/#24).
.padding(top = 48.dp), modifier = Modifier.align(Alignment.TopEnd),
) )
} }
} }

View file

@ -124,12 +124,19 @@ fun VideoCard(
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path -> video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current) // The request object must be remembered: ImageRequest has no value
// equality, and a fresh request per recomposition reset the
// shimmer's loaded state (and retried the load) on every frame —
// the fps regression on the TV.
val ctx = androidx.compose.ui.platform.LocalContext.current
val req = remember(video.id, video.thumbnailUrl) {
coil.request.ImageRequest.Builder(ctx)
// search-index results carry an absolute URL for their own instance // search-index results carry an absolute URL for their own instance
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path)) .data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs .size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
.build() .build()
AsyncImage( }
ShimmerAsyncImage(
model = req, model = req,
contentDescription = video.name, contentDescription = video.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -320,6 +327,48 @@ fun ShimmerCard(modifier: Modifier = Modifier) {
} }
} }
/**
* Coil image that shows the same faint shimmer as [ShimmerCard] while it loads
* (issue #28) — thumbnails, channel banners and avatars no longer pop in from a
* flat grey rectangle. The sweep runs only while loading: once the image (or an
* error) arrives, the overlay leaves composition and its animation disposes.
*/
@Composable
fun ShimmerAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
base: Color = Color(0xFF222222),
) {
var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) }
Box(modifier.background(base)) {
AsyncImage(
model = model,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.matchParentSize(),
onState = { state ->
loaded = state is coil.compose.AsyncImagePainter.State.Success ||
state is coil.compose.AsyncImagePainter.State.Error
},
)
if (!loaded) ShimmerFill(Modifier.matchParentSize(), base)
}
}
/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */
@Composable
private fun ShimmerFill(modifier: Modifier, base: Color) {
val sweep = rememberInfiniteTransition()
val x by sweep.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)),
)
Box(modifier.background(base).shimmer(x))
}
/** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ /** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */
fun Modifier.shimmer(progress: Float): Modifier = drawBehind { fun Modifier.shimmer(progress: Float): Modifier = drawBehind {
drawRect( drawRect(

View file

@ -1,8 +1,12 @@
package net.jeena.jtubetv.ui.screens.list package net.jeena.jtubetv.ui.screens.list
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.VideoLibrary
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -70,10 +74,42 @@ fun VideoListScreen(
), ),
) { ) {
val items = viewModel.items val items = viewModel.items
// per-item focus requesters so BACK from the player can refocus the opened card // per-item focus requesters so BACK from the player can refocus the opened card.
val requesters = androidx.compose.runtime.remember(items.size, epoch) { // A plain HashMap, like the rows: this is/was written during composition of
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>() // every card, and a snapshot map here re-invalidated the reading scopes on
// each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5).
// Keyed only on [epoch]: on items.size the map was dropped wholesale, and
// already-composed cards didn't re-populate it until their next recompose.
val requesters = androidx.compose.runtime.remember(epoch) {
HashMap<Int, FocusRequester>()
} }
// 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() }
if (source is ListSource.Channel) {
// Fetch the banner/avatar/counts for the channel page header.
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
// Park the grid at the top as soon as the page has content, BEFORE the
// first card takes focus (the effect below). It used to run after it:
// handing the first card the focus animates the grid up (a focused card
// that sits partly off-screen is pulled into view), and this effect then
// snapped it back down — the jump that was still reported on the channel
// page when the first video got focus (#27 review).
// The park has to be unconditional, and has to reset the row's offset,
// not just its index: the focus pull leaves the header row at index 0 but
// slid up by ~96 dp, so a guard on firstVisibleItemIndex > 0 never fires
// in that state and the top of the channel header stays clipped off
// screen (#31 review). scrollToItem(0) re-anchors both.
LaunchedEffect(items.isNotEmpty(), source) {
if (items.isNotEmpty()) {
runCatching { gridState.scrollToItem(0) }
}
}
}
// Pull initial focus out of the sidebar rail and into the content grid. // Pull initial focus out of the sidebar rail and into the content grid.
val initialFocus = remember { FocusRequester() } val initialFocus = remember { FocusRequester() }
// Which card was opened last — re-focus it when playback ends (UX report BUG-3). // Which card was opened last — re-focus it when playback ends (UX report BUG-3).
@ -82,6 +118,9 @@ fun VideoListScreen(
// With a running search the first RESULT gets focus; without one the // With a running search the first RESULT gets focus; without one the
// field does (the SearchHeader's own effect) — never both (issue #20). // field does (the SearchHeader's own effect) — never both (issue #20).
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) { if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
// The channel page parks its grid at the top just above; wait for that
// beat so the focus handover can never leave a scroll to undo (#27).
if (source is ListSource.Channel) kotlinx.coroutines.delay(250)
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() } runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
} }
} }
@ -96,17 +135,6 @@ fun VideoListScreen(
lastOpenedIndex = -1 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() }
if (source is ListSource.Channel) {
// Fetch the banner/avatar/counts for the channel page header.
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
}
// Row starts of the grid: LEFT from a row's first card opens the drawer // Row starts of the grid: LEFT from a row's first card opens the drawer
// (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with // (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with
// zero spacing, and the content pane eats 40dp (8 start + 32 end), so the // zero spacing, and the content pane eats 40dp (8 start + 32 end), so the
@ -165,18 +193,23 @@ fun VideoListScreen(
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) { ) {
// Page headers (search field / channel banner) live INSIDE the grid so // Page headers scroll WITH the content (issue #27 review: a pinned header
// they scroll with the content and never stack under the cards. // stayed put and the videos slid behind it). The header is compact enough
// that it's still fully visible when the first card takes focus.
if (source is ListSource.Search || source is ListSource.Channel) {
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
when (source) { when (source) {
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer) is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader( is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader, channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal, videoTotal = viewModel.videoTotal,
handle = source.handle,
onLeftEdge = onOpenDrawer,
) )
else -> {} else -> {}
} }
} }
}
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
item(key = "search-empty", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "search-empty", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
// Issue #20: an empty search stays usable — the field above keeps // Issue #20: an empty search stays usable — the field above keeps
@ -199,23 +232,16 @@ fun VideoListScreen(
// Placeholder cards while the page's first chunk loads (issue #19): // Placeholder cards while the page's first chunk loads (issue #19):
// shaped like the real cards, with the faint shimmer, on every // shaped like the real cards, with the faint shimmer, on every
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages). // VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { item(key = "initial-placeholders") {
LaunchedEffect(source) { viewModel.loadMore(force = true) } LaunchedEffect(source) { viewModel.loadMore(force = true) }
androidx.compose.foundation.layout.Column { // A single loading card, not a full fake grid (issue #31).
repeat(2) {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard( ShimmerCard(
modifier = Modifier modifier = Modifier
.padding(6.dp) .padding(6.dp)
.width(rowCardWidth()), .width(rowCardWidth() - 12.dp),
) )
} }
} }
}
}
}
}
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
val video: VideoDto = when (item) { val video: VideoDto = when (item) {
is VideoListItem.HistoryEntry -> item.history.video is VideoListItem.HistoryEntry -> item.history.video
@ -239,21 +265,17 @@ fun VideoListScreen(
} }
} }
if (viewModel.loadingMore) { if (viewModel.loadingMore) {
// Growing the list: placeholder row (issue #19) instead of the // Growing the list: a single loading card at the end (#25) so it's
// permanent spinner chip that used to sit under every list. // clear more is coming, without faking a whole row of content.
item(key = "load-more-placeholders") { item(key = "load-more-placeholder") {
androidx.compose.foundation.layout.Row {
repeat(cardsPerRow) {
ShimmerCard( ShimmerCard(
modifier = Modifier modifier = Modifier
.padding(6.dp) .padding(6.dp)
.width(rowCardWidth()), .width(rowCardWidth() - 12.dp),
) )
} }
} }
} }
}
}
} }
@Composable @Composable
@ -396,62 +418,135 @@ private fun ScopeChip(
} }
} }
/** Channel page header: banner strip, then avatar + display name + handle, /**
* a metadata strip (subscribers/videos/created + owner, issue #14) and the * Channel page header (issue #27): the banner is the backdrop of a fixed-height
* channel description snippet above the videos. */ * header, darkened with a bottom-up gradient, with the channel identity + stats
* and a reachable Subscribe button laid over it. It scrolls with the video grid.
*
* Its height is the same before and after the channel loads, so the cards never
* get pushed down when it arrives (issue #27 review), and a channel with no
* banner gets a colour gradient derived from its handle instead of flat black.
*/
@Composable @Composable
private fun ChannelHeader( private fun ChannelHeader(
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?, channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
videoTotal: Long = 0, videoTotal: Long = 0,
handle: String,
onLeftEdge: () -> Unit = {},
) { ) {
if (channelHeader == null) return val metaColor = Color(0xFFCFCFCF)
val metaColor = Color(0xFF9A9A9A) val banner = channelHeader?.banners?.maxByOrNull { it.width }
androidx.compose.foundation.layout.Column { val description = channelHeader?.description?.takeIf { it.isNotBlank() }
val banner = channelHeader.banners.maxByOrNull { it.width } val accent = remember(handle) { channelAccent(handle) }
androidx.compose.foundation.layout.Column(
Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
.background(Color.Black),
) {
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
// description sits in the black strip below it. The whole band is 160 dp
// (4 + 116 + 40) — under one card row — so the grid never reflows around it
// and the band stays fully on screen when the first card takes focus (#31).
Box(
Modifier
.fillMaxWidth()
.height(116.dp)
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
) {
if (banner != null) { if (banner != null) {
AsyncImage( ShimmerAsyncImage(
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.matchParentSize(),
.fillMaxWidth()
.height(120.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
) )
} }
// Darkening overlay, fading to solid black at the bottom so the banner
// blends seamlessly into the description strip beneath it.
Box(
Modifier
.matchParentSize()
.background(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to Color(0x33000000),
0.5f to Color(0x99000000),
1f to Color(0xFF000000),
),
),
)
// Identity + stats, bottom-left. The avatar sits beside the title (as it
// did before the redesign — user request). Only once the channel loaded;
// the box above keeps the height reserved in the meantime.
if (channelHeader != null) {
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
val avatar = channelHeader.avatars.maxByOrNull { it.width } val avatar = channelHeader.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
AsyncImage( ShimmerAsyncImage(
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
base = Color(0xFF333333),
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, top = 10.dp) .size(56.dp)
.size(52.dp)
.clip(androidx.compose.foundation.shape.CircleShape), .clip(androidx.compose.foundation.shape.CircleShape),
) )
} else {
// A channel with no icon gets a stand-in, not a gap (#27 review).
Box(
Modifier
.size(56.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0x40FFFFFF)),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
tint = Color(0xFFE8E8E8),
modifier = Modifier.size(42.dp),
)
} }
Column(modifier = Modifier.padding(start = 16.dp)) { }
Column(modifier = Modifier.padding(start = 14.dp)) {
Text( Text(
text = channelHeader.displayName, text = channelHeader.displayName,
color = MaterialTheme.colorScheme.onSurface, color = Color.White,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
// "by <owner> on <platform>" instead of the raw handle (user
// request): the handle read as noise on a personal instance.
val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name }
val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host
val byline = when {
owner != null && platform != null -> "by $owner on $platform"
owner != null -> "by $owner"
platform != null -> "on $platform"
else -> ""
}
if (byline.isNotBlank()) {
Text( Text(
text = channelHeader.name + "@" + channelHeader.host, text = byline,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = Color(0xFFBDBDBD),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
} }
} }
// Metadata strip between the identity and the videos (issue #14): the }
// counts SmartTube shows, plus creation date and the owning user.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp), modifier = Modifier.padding(top = 6.dp),
) { ) {
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
@ -462,30 +557,120 @@ private fun ChannelHeader(
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
} }
} }
val owner = channelHeader.ownerAccount }
if (owner != null) { // Subscribe button, top-right over the banner; LEFT opens the sidebar so
val host = channelHeader.host // the header is never a dead end (issue #27).
val platform = AppGraph.tokenStore.instanceName ?: host SubscribeButton(
Text( handle = handle,
text = "Owned by " + (owner.displayName ?: owner.name) + onLeftEdge = onLeftEdge,
(if (platform != null) " - " + platform else ""), modifier = Modifier
color = metaColor, .align(Alignment.TopEnd)
style = MaterialTheme.typography.bodySmall, .padding(16.dp),
maxLines = 1,
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
) )
} }
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description -> }
// Description strip in the black beneath the banner. Fixed height so the
// header measures the same before and after the channel loads (#27 review).
Box(
Modifier
.fillMaxWidth()
.height(40.dp)
.padding(start = 16.dp, end = 16.dp, top = 6.dp),
) {
if (description != null) {
Text( Text(
text = description, text = description,
color = Color(0xFFB8B8B8), color = Color(0xFFCCCCCC),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 3, maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
) )
} }
} }
}
}
/**
* Deterministic backdrop colours for a channel without a banner (#27 review):
* the handle's hash picks a hue, so every channel keeps the same recognisable
* gradient instead of a flat black header.
*/
private fun channelAccent(handle: String): List<Color> {
val hue = ((handle.hashCode() ushr 8) % 360 + 360) % 360
return listOf(
Color.hsv(hue.toFloat(), 0.45f, 0.42f),
Color.hsv(((hue + 38) % 360).toFloat(), 0.55f, 0.16f),
)
}
/**
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
* player's action: the server's follow-exists check can lag for federated
* channels, so a successful toggle trusts the user's action and re-syncs on the
* next screen entry. One request at a time ([busy]).
*/
@Composable
private fun SubscribeButton(
handle: String,
onLeftEdge: () -> Unit,
modifier: Modifier = Modifier,
) {
val repository = AppGraph.repository
val scope = androidx.compose.runtime.rememberCoroutineScope()
var subscribed by remember(handle) { mutableStateOf<Boolean?>(null) }
var busy by remember(handle) { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
LaunchedEffect(handle) {
repository.subscriptionExists(handle).onSuccess { subscribed = it }
}
val isSub = subscribed == true
val bg = when {
focused -> Color.White
isSub -> Color(0x33CE2B2B)
else -> Color(0xFFCE2B2B)
}
val fg = if (focused) Color(0xFF101010) else Color.White
Row(
modifier = modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
.background(bg)
.onFocusChanged { focused = it.isFocused }
.onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
) {
onLeftEdge(); true
} else false
}
.dpadCenterActivatable {
if (busy) return@dpadCenterActivatable
busy = true
scope.launch {
val wasSub = subscribed == true
val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
else repository.subscribe(handle).isSuccess
if (ok) subscribed = !wasSub
busy = false
}
}
.focusable()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
) {
androidx.compose.material3.Icon(
imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
contentDescription = null,
tint = fg,
modifier = Modifier.size(18.dp),
)
Text(
text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
color = fg,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
} }
@Composable @Composable

6
docs/peertube-logo.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="34" height="34" rx="12" fill="#E9DFDF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 16.3306V7L18 11.6653L11 16.3306Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 25.6613V16.3307L18 20.996L11 25.6613Z" fill="#737373"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 20.996V11.6653L25 16.3306L18 20.996Z" fill="#F1680D"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 908 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 689 KiB

Before After
Before After

View file

@ -15,6 +15,14 @@
set -euo pipefail set -euo pipefail
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
# Prefer the working host SDK at /opt/android-sdk: the emulator that
# ~/Android/Sdk auto-updated to on Oct 11 (37.2.12) fails its own KVM probe
# ("/dev/kvm is not found") with KVM perfectly healthy — /opt/android-sdk and
# the container-home copy both pass -accel-check on the same kernel, so that
# install is broken. /opt/android-sdk is the documented host SDK anyway.
if [ -z "${ANDROID_SDK_ROOT:-}" ] && [ -x /opt/android-sdk/emulator/emulator ]; then
SDK=/opt/android-sdk
fi
EMU="$SDK/emulator/emulator" EMU="$SDK/emulator/emulator"
ADB="$SDK/platform-tools/adb" ADB="$SDK/platform-tools/adb"
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager" SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
@ -76,6 +84,41 @@ if [ -e /dev/kvm ]; then
echo "/dev/kvm found — hardware acceleration on" echo "/dev/kvm found — hardware acceleration on"
fi fi
# Pin the emulator process to the integrated GPU so the discrete card stays free
# for AI inference (windowed mode uses the host GL/Vulkan stack; headless runs on
# swiftshader — pure software — and never touches any GPU at all). Discovered
# from the PCI tree: first display device NOT driven by the nvidia driver.
IGPU=""
for dev in /sys/bus/pci/devices/*; do
class=$(cat "$dev/class" 2>/dev/null) || continue
case "$class" in
0x0300*|0x0380*) ;; # VGA / display controller
*) continue ;;
esac
if [ "$(basename "$(readlink "$dev/driver" 2>/dev/null)")" = nvidia ]; then continue; fi
IGPU=$(basename "$dev")
break
done
if [ -n "$IGPU" ]; then
# Mesa GL/GLES: force the Mesa vendors, then the Intel render device.
export __GLX_VENDOR_LIBRARY_NAME=mesa
export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json
export DRI_PRIME="pci-${IGPU//./_}"
# Vulkan: restrict the loader to the iGPU's ICD (both var names — loader
# version differences).
for icd in /usr/share/vulkan/icd.d/intel_icd.x86_64.json \
/usr/share/vulkan/icd.d/Intel_icd.x86_64.json; do
if [ -f "$icd" ]; then
export VK_ICD_FILENAMES="$icd"
export VK_DRIVER_FILES="$icd"
break
fi
done
echo "GPU pinned to iGPU $IGPU (Mesa/Intel env) — dGPU untouched"
else
echo "no iGPU found — leaving GPU selection to the driver (dGPU may be used)"
fi
# --- build combined adb vendor-keys file ------------------------------------ # --- build combined adb vendor-keys file ------------------------------------
# The TV system image is a user build: adb push to /data/misc/adb/adb_keys is # The TV system image is a user build: adb push to /data/misc/adb/adb_keys is
# blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead. # blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead.