Compare commits
11 commits
main
...
tools/scre
| Author | SHA1 | Date | |
|---|---|---|---|
| 671d2dd661 | |||
| 35f2633fa5 | |||
| e9d7b473fb | |||
| e8117ab08e | |||
| 069dfe38c4 | |||
| 39d3b12182 | |||
| 84a0527e20 | |||
| 4176edfd69 | |||
| e485caa855 | |||
| a6d0f9ec6c | |||
| d2b40d5f10 |
|
|
@ -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).
|
||||||
85
BACKLOG.md
|
|
@ -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.
|
|
||||||
88
README.md
|
|
@ -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" /> 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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,68 +166,84 @@ private fun InfoColumn(
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
|
val annotated = buildAnnotatedString {
|
||||||
|
var rest = description
|
||||||
|
while (true) {
|
||||||
|
val match = UrlPattern.find(rest) ?: break
|
||||||
|
append(rest.substringBefore(match.value))
|
||||||
|
val url = match.value.trimEnd('.', ',', ')')
|
||||||
|
withLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url,
|
||||||
|
TextLinkStyles(
|
||||||
|
SpanStyle(
|
||||||
|
color = Color(0xFF66B2FF),
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ _ -> open(url) },
|
||||||
|
),
|
||||||
|
) { append(url) }
|
||||||
|
rest = rest.substringAfter(match.value)
|
||||||
|
}
|
||||||
|
append(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val descFocus = remember { FocusRequester() }
|
||||||
val firstLinkFocus = remember { FocusRequester() }
|
val firstLinkFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(links) {
|
// Focus the description so the D-pad can scroll it right away (issue #23):
|
||||||
if (links.isNotEmpty()) {
|
// a description with no links used to have nothing focusable, so a long
|
||||||
kotlinx.coroutines.delay(150)
|
// text could never be scrolled down with the remote. Fall back to the
|
||||||
runCatching { firstLinkFocus.requestFocus() }
|
// first link when there is no description text.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(150)
|
||||||
|
runCatching {
|
||||||
|
if (description.isNotBlank()) descFocus.requestFocus()
|
||||||
|
else firstLinkFocus.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
item(key = "desc") {
|
DescriptionScroll(
|
||||||
Text(
|
annotated = annotated,
|
||||||
text = buildAnnotatedString {
|
focusRequester = descFocus,
|
||||||
var rest = description
|
onDismiss = onDismiss,
|
||||||
while (true) {
|
onLeaveBottom = {
|
||||||
val match = UrlPattern.find(rest) ?: break
|
if (links.isNotEmpty()) {
|
||||||
append(rest.substringBefore(match.value))
|
runCatching { firstLinkFocus.requestFocus() }.isSuccess
|
||||||
val url = match.value.trimEnd('.', ',', ')')
|
} else false
|
||||||
withLink(
|
},
|
||||||
LinkAnnotation.Url(
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
url,
|
)
|
||||||
TextLinkStyles(
|
|
||||||
SpanStyle(
|
|
||||||
color = Color(0xFF66B2FF),
|
|
||||||
textDecoration = TextDecoration.Underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
{ _ -> open(url) },
|
|
||||||
),
|
|
||||||
) { append(url) }
|
|
||||||
rest = rest.substringAfter(match.value)
|
|
||||||
}
|
|
||||||
append(rest)
|
|
||||||
},
|
|
||||||
color = TextMain,
|
|
||||||
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()) {
|
||||||
focusRequester = if (index == 0) firstLinkFocus else null,
|
{ runCatching { descFocus.requestFocus() } }
|
||||||
onClick = { open(url) },
|
} else null,
|
||||||
) {
|
atTop = index == 0 && description.isBlank(),
|
||||||
Text(
|
atBottom = index == links.size - 1,
|
||||||
text = linkLabel(url),
|
focusRequester = if (index == 0) firstLinkFocus else null,
|
||||||
color = TextMain,
|
onClick = { open(url) },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
maxLines = 1,
|
Text(
|
||||||
)
|
text = linkLabel(url),
|
||||||
|
color = TextMain,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +251,64 @@ 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. */
|
||||||
private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)
|
private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
if (source is ListSource.Search) {
|
||||||
when (source) {
|
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||||
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
|
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 {
|
ShimmerCard(
|
||||||
repeat(cardsPerRow) {
|
modifier = Modifier
|
||||||
ShimmerCard(
|
.padding(6.dp)
|
||||||
modifier = Modifier
|
.width(rowCardWidth() - 12.dp),
|
||||||
.padding(6.dp)
|
)
|
||||||
.width(rowCardWidth()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -396,95 +403,216 @@ 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)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// Darkening overlay, fading to solid black at the bottom so the banner
|
||||||
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
// blends seamlessly into the description strip beneath it.
|
||||||
if (avatar != null) {
|
Box(
|
||||||
AsyncImage(
|
Modifier
|
||||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
.matchParentSize()
|
||||||
contentDescription = null,
|
.background(
|
||||||
contentScale = ContentScale.Crop,
|
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
modifier = Modifier
|
0f to Color(0x33000000),
|
||||||
.padding(start = 16.dp, top = 10.dp)
|
0.5f to Color(0x99000000),
|
||||||
.size(52.dp)
|
1f to Color(0xFF000000),
|
||||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
),
|
||||||
)
|
),
|
||||||
}
|
)
|
||||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
||||||
Text(
|
// did before the redesign — user request).
|
||||||
text = channelHeader.displayName,
|
Column(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.align(Alignment.BottomStart)
|
||||||
maxLines = 1,
|
.padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = channelHeader.name + "@" + channelHeader.host,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Metadata strip between the identity and the videos (issue #14): the
|
|
||||||
// counts SmartTube shows, plus creation date and the owning user.
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||||
if (videoCount > 0) {
|
if (avatar != null) {
|
||||||
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
|
ShimmerAsyncImage(
|
||||||
|
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
base = Color(0xFF333333),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||||
|
Text(
|
||||||
|
text = channelHeader.displayName,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
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 = byline,
|
||||||
|
color = Color(0xFFBDBDBD),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (channelHeader.createdAt != null) {
|
Row(
|
||||||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
||||||
|
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||||
|
if (videoCount > 0) {
|
||||||
|
headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor)
|
||||||
|
}
|
||||||
|
if (channelHeader.createdAt != null) {
|
||||||
|
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val owner = channelHeader.ownerAccount
|
// Subscribe button, top-right over the banner; LEFT opens the sidebar so
|
||||||
if (owner != null) {
|
// the header is never a dead end (issue #27).
|
||||||
val host = channelHeader.host
|
SubscribeButton(
|
||||||
val platform = AppGraph.tokenStore.instanceName ?: host
|
handle = handle,
|
||||||
Text(
|
onLeftEdge = onLeftEdge,
|
||||||
text = "Owned by " + (owner.displayName ?: owner.name) +
|
modifier = Modifier
|
||||||
(if (platform != null) " - " + platform else ""),
|
.align(Alignment.TopEnd)
|
||||||
color = metaColor,
|
.padding(16.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
)
|
||||||
maxLines = 1,
|
}
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
// 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) {
|
||||||
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
|
Text(
|
||||||
Text(
|
text = description,
|
||||||
text = description,
|
color = Color(0xFFCCCCCC),
|
||||||
color = Color(0xFFB8B8B8),
|
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 = 12.dp),
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
6
docs/peertube-logo.svg
Normal 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 |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 698 KiB |
29
tools/mock/README.md
Normal 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
|
|
@ -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()
|
||||||