Compare commits

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

11 commits

Author SHA1 Message Date
671d2dd661 tools: relabel upstream host in the mock so the Channels screen populates
The app decides a video/channel is "local" by matching its host against the
instance host; relabel peertube2.cpy.re to the proxy's own host in the JSON
(host fields only, never URLs) and map handles back when proxying, so the
Channels screen and channel pages work through the mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 13:06:44 +00:00
35f2633fa5 tools: screenshot mock backend (branch-only)
A thin HTTP proxy to the public PeerTube demo instance with overrides so the
authenticated screens populate without an account, for regenerating the README
screenshots without the private instance. Includes a debug-only cleartext flag
so a debug build can reach the proxy over HTTP. Kept on this branch; main stays
clean. See tools/mock/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:58:09 +00:00
e9d7b473fb 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:37:51 +00:00
e8117ab08e 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:49 +00:00
069dfe38c4 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:25 +00:00
39d3b12182 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
84a0527e20 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
4176edfd69 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
e485caa855 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
a6d0f9ec6c 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
d2b40d5f10 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).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 12:25:12 +00:00
19 changed files with 745 additions and 446 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,91 @@
<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. Open a channel for its banner, its stats and all its videos — and a
Subscribe button you can reach with the remote.
<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 **The player** speaks remote: play and pause, seek back and forward, a seek bar
with position and duration. Watching is reported to the server every 20 with position and duration, and the video's info, comments and playlists a
seconds, so where you left off survives the app. 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 +122,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,7 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only: allow plain HTTP so local mock instances (e.g. the <!--
screenshot/demo server on 10.0.2.2) work without TLS. Release keeps the Debug-only overlay: allow cleartext HTTP so a debug build can talk to the local
default policy. --> mock PeerTube backend (tools/mock) over http://10.0.2.2:PORT for README
screenshots. Merged into the manifest for the debug build type only; the
release build and `main` are unaffected. See tools/mock/README.md.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" /> <application android:usesCleartextTraffic="true" />
</manifest> </manifest>

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
@ -93,15 +97,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 +166,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,26 +187,53 @@ private fun InfoColumn(
rest = rest.substringAfter(match.value) rest = rest.substringAfter(match.value)
} }
append(rest) append(rest)
}
val descFocus = remember { FocusRequester() }
val firstLinkFocus = remember { FocusRequester() }
// Focus the description so the D-pad can scroll it right away (issue #23):
// a description with no links used to have nothing focusable, so a long
// text could never be scrolled down with the remote. Fall back to the
// first link when there is no description text.
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
runCatching {
if (description.isNotBlank()) descFocus.requestFocus()
else firstLinkFocus.requestFocus()
}
}
Column(Modifier.fillMaxSize()) {
if (description.isNotBlank()) {
DescriptionScroll(
annotated = annotated,
focusRequester = descFocus,
onDismiss = onDismiss,
onLeaveBottom = {
if (links.isNotEmpty()) {
runCatching { firstLinkFocus.requestFocus() }.isSuccess
} else false
}, },
color = TextMain, modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
) )
} }
}
if (links.isNotEmpty()) { if (links.isNotEmpty()) {
item(key = "linkshdr") {
Text( Text(
text = "Links", text = "Links",
color = TextDim, color = TextDim,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp), modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
) )
} LazyColumn(Modifier.heightIn(max = 220.dp)) {
links.forEachIndexed { index, url -> links.forEachIndexed { index, url ->
item(key = "l" + index + url) { item(key = "l" + index + url) {
SidebarRow( SidebarRow(
onLeft = onDismiss, onLeft = onDismiss,
onUp = if (index == 0 && description.isNotBlank()) {
{ runCatching { descFocus.requestFocus() } }
} else null,
atTop = index == 0 && description.isBlank(),
atBottom = index == links.size - 1,
focusRequester = if (index == 0) firstLinkFocus else null, focusRequester = if (index == 0) firstLinkFocus else null,
onClick = { open(url) }, onClick = { open(url) },
) { ) {
@ -228,6 +248,65 @@ private fun InfoColumn(
} }
} }
} }
}
}
/**
* 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,
onLeaveBottom: () -> Boolean,
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
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
when (ke.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_DOWN ->
if (scroll.value < scroll.maxValue) {
scope.launch { scroll.animateScrollBy(step) }
true
} else onLeaveBottom()
KeyEvent.KEYCODE_DPAD_UP ->
if (scroll.value > 0) {
scope.launch { scroll.animateScrollBy(-step) }
true
} else true // nothing above the description; stay put
KeyEvent.KEYCODE_DPAD_LEFT -> {
onDismiss(); true
}
else -> false
}
}
.verticalScroll(scroll)
.onSizeChanged { viewportPx = it.height },
) {
Text(
text = annotated,
color = TextMain,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
} }
/** Link click listener: the annotation callback shape. */ /** Link click listener: the annotation callback shape. */

View file

@ -37,6 +37,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,9 +64,9 @@ 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
@ -93,10 +94,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,20 +123,9 @@ 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()
}
}
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:
@ -224,11 +210,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)
} }
@ -302,12 +283,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
.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 { 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 ->
@ -439,6 +414,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>,
@ -481,6 +457,8 @@ private fun ChannelRow(
LazyRow( LazyRow(
state = rowState, state = rowState,
// Land on the last-focused card when moving between rows (issue #29).
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@ -553,7 +531,7 @@ 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( ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
@ -567,7 +545,7 @@ fun ChannelCard(
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
AsyncImage( ShimmerAsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
@ -576,12 +554,12 @@ fun ChannelCard(
.build(), .build(),
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

@ -14,6 +14,7 @@ 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 +41,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 +57,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 +73,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 +101,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 +156,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 +164,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 +172,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 +182,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 +194,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 +213,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 +272,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 +297,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 +317,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 +327,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 +346,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 +356,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 +386,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 +405,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()
@ -489,6 +455,10 @@ fun FocusScrollRow(
} }
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.
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
@ -512,5 +482,14 @@ fun FocusScrollRow(
) )
afterItem(index, video) afterItem(index, video)
} }
if (trailingLoading) {
item(key = "trailing-loading") {
ShimmerCard(
modifier = Modifier
.padding(horizontal = 4.dp)
.width(rowCardWidth()),
)
}
}
} }
} }

View file

@ -114,19 +114,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 +130,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 +155,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
}, },

View file

@ -129,7 +129,7 @@ fun VideoCard(
.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 +320,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,11 @@
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.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
@ -159,22 +162,29 @@ fun VideoListScreen(
return return
} }
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
// Channel page: the header is PINNED above the grid (issue #27) rather than
// scrolling away as the first grid item — auto-focusing the first video card
// no longer hides the banner, and the Subscribe button on it stays reachable.
if (source is ListSource.Channel) {
ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
handle = source.handle,
onLeftEdge = onOpenDrawer,
)
}
LazyVerticalGrid( LazyVerticalGrid(
// Same card size as the Home rows: ~4 fit per row (user request). // Same card size as the Home rows: ~4 fit per row (user request).
columns = GridCells.Adaptive(rowCardWidth() - 12.dp), columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
) { ) {
// Page headers (search field / channel banner) live INSIDE the grid so // Search field still scrolls with its results (its own header item);
// they scroll with the content and never stack under the cards. // the channel banner is pinned above the grid instead (issue #27).
if (source is ListSource.Search) {
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) { SearchHeader(viewModel, onOpenDrawer)
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
is ListSource.Channel -> ChannelHeader(
channelHeader = viewModel.channelHeader,
videoTotal = viewModel.videoTotal,
)
else -> {}
} }
} }
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
@ -239,21 +249,18 @@ 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 +403,113 @@ 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's pinned above the grid so
* it never scrolls away, and the video cards start directly beneath it.
*/
@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 if (channelHeader == null) return
val metaColor = Color(0xFF9A9A9A) val metaColor = Color(0xFFCFCFCF)
androidx.compose.foundation.layout.Column {
val banner = channelHeader.banners.maxByOrNull { it.width } val banner = channelHeader.banners.maxByOrNull { it.width }
val description = channelHeader.description?.takeIf { it.isNotBlank() }
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: a fixed height with the identity + stats anchored to its
// bottom. A long description flows into the black strip BELOW it rather than
// being pushed up over the banner or clipped (user request).
Box(
Modifier
.fillMaxWidth()
.height(178.dp)
.background(Color(0xFF1C1C1C)),
) {
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).
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),
) )
} }
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,32 +520,102 @@ 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 in the black beneath the banner: a long one grows the header
// downward, fading out of the banner that already darkens into black.
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 = 3,
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), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
) )
} }
} }
} }
/**
* 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
private fun headerStat( private fun headerStat(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,

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.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 572 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 635 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Before After
Before After

29
tools/mock/README.md Normal file
View file

@ -0,0 +1,29 @@
# Screenshot mock backend
Throwaway tooling to regenerate the README screenshots without exposing the
private family content on the real instance. Lives on the `tools/screenshot-mock`
branch only — `main` never carries the mock or the cleartext flag.
`mock_peertube.py` is a thin HTTP proxy to the public PeerTube demo instance
(`peertube2.cpy.re`, real CC/Blender content) with a handful of overrides so the
authenticated screens are populated without an account (login always succeeds, a
"Demo User" with an avatar, Continue watching / My videos / Subscriptions each
show a different slice so thumbnails don't repeat, My videos gets privacy badges,
and a channel page shows the full video grid). Media and images still load
straight from peertube2 over HTTPS; only the JSON and thumbnails pass through the
proxy. The app itself runs unmodified — the only app-side change is the
debug-only `android:usesCleartextTraffic="true"` in `app/src/debug/` that lets a
debug build reach the proxy over HTTP.
## Regenerate the screenshots
1. `git checkout tools/screenshot-mock`
2. Start the proxy on the host: `python3 tools/mock/mock_peertube.py` (port 8080;
needs only Python 3 stdlib).
3. Build + install the debug build on the emulator:
`./gradlew :app:assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk`
4. On the setup screen enter `http://10.0.2.2:8080` (the emulator's alias for the
host) and sign in with any username/password.
5. Drive the UI with `adb shell input …` and `adb shell screencap` to capture
`home`, `drawer`, `myvideos`, `channels`, `player`, and a channel page.
6. Commit the PNGs on `main` (only the images), not here.

163
tools/mock/mock_peertube.py Normal file
View file

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Mock PeerTube backend for README screenshots.
It is a thin HTTP proxy to the public PeerTube demo instance
(peertube2.cpy.re, real CC/Blender content) with a few overrides so the
authenticated screens are populated without a real account:
- login always succeeds (oauth-clients/local + users/token are canned),
- /users/me is a "Demo User" with an avatar,
- Continue watching / My videos / Subscriptions each proxy a DIFFERENT
window of the public video list, so the rows don't repeat thumbnails,
- My videos gets Private/Internal/Public badges,
- a channel page shows the full video grid (channel .../videos is widened),
- a video opened from Continue watching gets a fake resume position.
Everything else (video lists, video detail with its absolute HLS URLs, channel
banners/avatars, config, thumbnails) is proxied verbatim, so media and images
load straight from peertube2 over HTTPS; only the JSON + thumbnails pass through
here. The app runs UNMODIFIED against it (a debug-only cleartext flag in
app/src/debug lets it talk HTTP to this proxy).
Usage:
python3 tools/mock/mock_peertube.py # listens on 0.0.0.0:8080
Then in the app's setup screen use http://10.0.2.2:8080 (emulator) and sign
in with any username/password.
"""
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = "https://peertube2.cpy.re"
UP_HOST = "peertube2.cpy.re"
AVATAR = UPSTREAM + "/lazy-static/avatars/73e78544-cbae-439e-8c84-98b0756fbde7.jpg"
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
# How the emulator addresses this proxy. The app decides a video/channel is
# "local" by matching its host against the instance host, so we relabel the
# upstream host to ours in the JSON (only the `host` fields, never URLs, so
# thumbnails/media keep pointing at peertube2).
MOCK_HOST = f"10.0.2.2:{PORT}"
def relabel_hosts(obj):
if isinstance(obj, dict):
return {k: (MOCK_HOST if k == "host" and v == UP_HOST else relabel_hosts(v)) for k, v in obj.items()}
if isinstance(obj, list):
return [relabel_hosts(x) for x in obj]
return obj
def fetch(path_with_query):
req = urllib.request.Request(UPSTREAM + path_with_query, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.headers.get_content_type(), r.read()
def fetch_videos(start, count, sort="-publishedAt"):
_, _, body = fetch(f"/api/v1/videos?isLocal=true&start={start}&count={count}&sort={sort}")
return json.loads(body)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): # quiet
pass
def _json(self, obj, code=200):
data = json.dumps(relabel_hosts(obj)).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _raw(self, code, ctype, body):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _q(self, query, name, default):
return urllib.parse.parse_qs(query).get(name, [default])[0]
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
self.rfile.read(int(self.headers.get("Content-Length", 0) or 0))
if path == "/api/v1/users/token":
return self._json({
"token_type": "Bearer", "access_token": "demo",
"refresh_token": "demo", "expires_in": 86400,
})
if re.match(r"^/api/v1/videos/[^/]+/watching$", path):
self.send_response(204)
self.end_headers()
return
self._json({}, 204)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path, query = parsed.path, parsed.query
start = lambda off: int(self._q(query, "start", "0")) + off # noqa: E731
count = int(self._q(query, "count", "25"))
if path == "/api/v1/oauth-clients/local":
return self._json({"client_id": "demo", "client_secret": "demo"})
if path == "/api/v1/users/me":
return self._json({
"username": "demo",
"account": {"displayName": "Demo User",
"avatars": [{"width": 120, "fileUrl": AVATAR}]},
})
if path == "/api/v1/config":
return self._json({
"instance": {"name": "PeerTube Demo",
"shortDescription": "Public demo content"},
"search": {"searchIndex": {"enabled": False}},
})
if path == "/api/v1/users/me/videos":
data = fetch_videos(start(4), count)
for i, v in enumerate(data.get("data", [])):
v["privacy"] = {0: {"id": 3, "label": "Private"},
1: {"id": 4, "label": "Internal"}}.get(i % 3, {"id": 1, "label": "Public"})
return self._json(data)
if path == "/api/v1/users/me/history/videos":
return self._json(fetch_videos(start(8), count))
if path == "/api/v1/users/me/subscriptions/videos":
return self._json(fetch_videos(start(16), count))
if path.startswith("/api/v1/users/me/subscriptions/exist"):
return self._json({})
if re.match(r"^/api/v1/video-channels/[^/]+/videos$", path):
return self._json(fetch_videos(start(0), count)) # full grid on the channel page
m = re.match(r"^/api/v1/videos/([^/]+)$", path)
if m and self._q(query, "userHistory", "") == "true":
_, _, body = fetch(path)
detail = json.loads(body)
detail["userHistory"] = {"currentTime": int(detail.get("duration", 0) * 0.35)}
return self._json(detail)
# Everything else (video lists, detail, channels, thumbnails) is proxied.
# A handle the app built with our host (name@10.0.2.2:PORT) is mapped back
# to the upstream host before we ask peertube2.
upstream = self.path.replace(MOCK_HOST, UP_HOST).replace(
MOCK_HOST.replace(":", "%3A"), UP_HOST)
try:
code, ctype, body = fetch(upstream)
if "json" in ctype:
self._json(json.loads(body), code) # relabel hosts in proxied JSON too
else:
self._raw(code, ctype, body)
except urllib.error.HTTPError as e:
self._raw(e.code, "application/json", e.read())
except Exception as e: # noqa: BLE001
self._raw(502, "text/plain", str(e).encode())
if __name__ == "__main__":
print(f"Mock PeerTube proxy on http://0.0.0.0:{PORT} -> {UPSTREAM}")
print(f"Emulator: set instance URL to http://10.0.2.2:{PORT} and sign in with anything.")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()