Compare commits
29 commits
d016805688
...
22d87d2398
| Author | SHA1 | Date | |
|---|---|---|---|
| 22d87d2398 | |||
| 182650414c | |||
| c0a7e94b14 | |||
| e7b15f819e | |||
| 35e0f31a74 | |||
| 0ab19b835c | |||
| 54dfe68726 | |||
| f371a21e17 | |||
| b9419e6a65 | |||
| 45fd1b963e | |||
| 3854c6999a | |||
| 59d93974b5 | |||
| 75a0153141 | |||
| d6fab03c47 | |||
| d5040f8035 | |||
| e6792a98fd | |||
| 0ca17cfd00 | |||
| 9f69cabf79 | |||
| 9ce367b601 | |||
| b78e7187c0 | |||
| bdf9b35ebd | |||
| a08f3b956c | |||
| f2d63fdb1c | |||
| 21d8290f1d | |||
| b2af6fc1a5 | |||
| aea649eab9 | |||
| 8eeffe834d | |||
| 349c1aa447 | |||
| 6307addfb8 |
|
|
@ -113,10 +113,11 @@ the workflow is strict:
|
|||
|
||||
## Scope discipline
|
||||
|
||||
v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in
|
||||
uploading, editing, subscriptions management, deletion or content creation — the user
|
||||
explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md`
|
||||
first, then implement.
|
||||
Shipped features live in `README.md`; anything else is tracked as Forgejo issues at
|
||||
git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content
|
||||
creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is
|
||||
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
|
||||
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.
|
||||
94
README.md
|
|
@ -4,71 +4,97 @@
|
|||
<img src="docs/jtube-logo.svg" width="150" alt="The JTube TV logo, a little orange TV" />
|
||||
</p>
|
||||
|
||||
JTube TV is an Android TV app for [PeerTube](https://joinpeertube.org/). It puts
|
||||
a PeerTube instance on the big screen the way SmartTube puts YouTube there:
|
||||
browse from the couch, play with the remote, nothing else in the way.
|
||||
<p align="center">
|
||||
<em>Watch your PeerTube instance from the couch, with the remote.</em>
|
||||
</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
|
||||
is [tube.jeena.net](https://tube.jeena.net), my own instance, where most videos
|
||||
are private family uploads. Signing in matters because "My videos" shows them
|
||||
all, privacy levels included.
|
||||
in the sign-in screen is [tube.jeena.net](https://tube.jeena.net), the author's
|
||||
own instance, but you can point it at yours.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
The home screen. Continue watching picks up at the position the server saved,
|
||||
new videos first, page after page.
|
||||
**Home.** Three rows: *Continue watching* picks up each video at the position
|
||||
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">
|
||||
<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>
|
||||
|
||||
A slide-in drawer opens with the left D-pad button. My videos shows your own
|
||||
uploads, every privacy level included, with a badge on anything not public.
|
||||
**The menu.** A sidebar slides in — press the left D-pad at the edge of a row,
|
||||
or just press Back from anywhere in a list. It jumps to Home, My videos,
|
||||
Channels, Search, Subscriptions and History.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/drawer.png" width="800" alt="Menu drawer with the destinations" />
|
||||
</p>
|
||||
|
||||
**My videos.** Everything you own, every privacy level, with a badge on
|
||||
anything that isn't public.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/myvideos.png" width="800" alt="My videos grid with Private and Internal badges" />
|
||||
</p>
|
||||
|
||||
Channels, one row per owner, with video and play counts on every card.
|
||||
**Channels.** One row per owner, with video, subscriber and play counts on
|
||||
every card.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/channels.png" width="800" alt="Channels screen with channel cards" />
|
||||
</p>
|
||||
|
||||
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
|
||||
seconds, so where you left off survives the app.
|
||||
Open a channel for its banner, its stats and all its videos — with a Subscribe
|
||||
button you can reach with the remote.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/channel.png" width="800" alt="A channel page: banner, stats, Subscribe button and the channel's videos" />
|
||||
</p>
|
||||
|
||||
**The player** speaks remote: play and pause, seek back and forward, a seek bar
|
||||
with position and duration, and the video's info, comments and playlists a
|
||||
button away. Watching is reported to the server every 20 seconds, so where you
|
||||
left off survives closing the app.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/player.png" width="800" alt="Player with controls, seek bar and title" />
|
||||
</p>
|
||||
|
||||
## What it does in v0.1
|
||||
## What it does
|
||||
|
||||
- Sign in once. The app exchanges your username and password for OAuth tokens
|
||||
and stores only those, encrypted with the Android Keystore. The password
|
||||
itself is never kept.
|
||||
- Home with Continue watching and New videos.
|
||||
- Browse the instance's public videos, and search them by title.
|
||||
- My videos: every video you own, private, internal and unlisted included.
|
||||
- Channels: local channels with their videos, plus your subscriptions and your
|
||||
server-side watch history.
|
||||
- Player: HLS, with a progressive MP4 fallback, resume from the saved
|
||||
position, and watching reports.
|
||||
- **Sign in once.** The app trades your username and password for OAuth tokens
|
||||
and keeps only those, encrypted with the Android Keystore. The password is
|
||||
never stored.
|
||||
- **Home** with Continue watching, new local videos and new videos from your
|
||||
subscriptions.
|
||||
- **Browse and search** the instance's public videos by title.
|
||||
- **My videos** — every video you own, private, internal and unlisted included.
|
||||
- **Channels** — local channels with their videos, plus your subscriptions and
|
||||
your server-side watch history.
|
||||
- **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,
|
||||
deleting, managing subscriptions. That's tracked in
|
||||
[BACKLOG.md](BACKLOG.md).
|
||||
What it deliberately does not do: uploading, editing or deleting content.
|
||||
|
||||
## Build
|
||||
|
||||
|
|
@ -102,4 +128,6 @@ A single-module app, deliberately small:
|
|||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||
import net.jeena.jtubetv.ui.list.Snapshots
|
||||
|
||||
/** Where the list's content comes from. */
|
||||
sealed interface ListSource {
|
||||
|
|
@ -56,16 +55,6 @@ class VideoListViewModel(
|
|||
var videoTotal by androidx.compose.runtime.mutableStateOf(0L)
|
||||
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 {
|
||||
// Probe whether the instance allows the search-index ("Vidiverse") scope.
|
||||
if (source is ListSource.Search) {
|
||||
|
|
@ -74,24 +63,10 @@ class VideoListViewModel(
|
|||
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
|
||||
}
|
||||
}
|
||||
val context = AppGraph.appContext
|
||||
if (context != null && !shouldSkipSnapshot) {
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No snapshot instant-paint any more (issue #25): the saved-list cache
|
||||
// repainted stale rows that then jumped when the fresh page landed with
|
||||
// newer videos. Screens now show the loading card until the first page
|
||||
// arrives; images stay fast via Coil's own disk cache.
|
||||
}
|
||||
|
||||
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>> {
|
||||
|
|
@ -150,22 +111,9 @@ class VideoListViewModel(
|
|||
fun loadChannelHeader(repository: PeerTubeRepository) {
|
||||
val s = source
|
||||
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 {
|
||||
repository.videoChannel(s.handle).getOrNull()?.let { 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.clickable
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -43,6 +47,8 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
|
|
@ -93,15 +99,16 @@ fun PlayerSidebar(
|
|||
.width(SIDEBAR_WIDTH)
|
||||
.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 = when (mode) {
|
||||
"playlists" -> "Playlists"
|
||||
"info" -> "Info"
|
||||
else -> "Comments"
|
||||
},
|
||||
text = if (mode == "playlists") "Playlists" else detail.name,
|
||||
color = Color.White,
|
||||
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) {
|
||||
"playlists" -> PlaylistsColumn(detail, onDismiss)
|
||||
|
|
@ -161,19 +168,7 @@ private fun InfoColumn(
|
|||
.distinct()
|
||||
.toList()
|
||||
|
||||
val firstLinkFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(links) {
|
||||
if (links.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
runCatching { firstLinkFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
if (description.isNotBlank()) {
|
||||
item(key = "desc") {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
val annotated = buildAnnotatedString {
|
||||
var rest = description
|
||||
while (true) {
|
||||
val match = UrlPattern.find(rest) ?: break
|
||||
|
|
@ -194,41 +189,108 @@ private fun InfoColumn(
|
|||
rest = rest.substringAfter(match.value)
|
||||
}
|
||||
append(rest)
|
||||
},
|
||||
}
|
||||
|
||||
val descFocus = remember { FocusRequester() }
|
||||
// The description owns the focus for the whole panel: UP/DOWN scroll it and are
|
||||
// always consumed, so scrolling can never strand focus somewhere it can't come
|
||||
// back from (issue #23 review: "it loses focus and it's impossible to scroll up").
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
runCatching { descFocus.requestFocus() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (description.isBlank()) {
|
||||
SidebarFallback("No description.", onDismiss)
|
||||
} else {
|
||||
DescriptionScroll(
|
||||
annotated = annotated,
|
||||
focusRequester = descFocus,
|
||||
onDismiss = onDismiss,
|
||||
// Links stay inline in the running text (issue #23 review) instead of
|
||||
// being repeated in a list underneath; OK opens the first one.
|
||||
onActivate = { links.firstOrNull()?.let { open(it) } },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a
|
||||
* LazyColumn can't be scrolled with the remote — nothing in it takes focus — so
|
||||
* long descriptions were unreadable past the first screenful. This box takes
|
||||
* focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands
|
||||
* focus to the link list (if any).
|
||||
*/
|
||||
@Composable
|
||||
private fun DescriptionScroll(
|
||||
annotated: androidx.compose.ui.text.AnnotatedString,
|
||||
focusRequester: FocusRequester,
|
||||
onDismiss: () -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scroll = androidx.compose.foundation.rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
var viewportPx by remember { mutableStateOf(0) }
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
// One press moves a third of a screenful, so consecutive stops
|
||||
// overlap and every line of a long description gets shown (issue
|
||||
// #23 review: the old 85% step jumped past the middle of the text
|
||||
// on tube.jeena.net/w/p4HtZrDCMbPfGYpMfG9fMN).
|
||||
val step = (viewportPx * 0.34f).coerceAtLeast(150f)
|
||||
val code = ke.nativeKeyEvent.keyCode
|
||||
// Match Compose's normalised key AND the raw keycode: remotes don't all
|
||||
// report the same native codes, and matching only the raw one left UP
|
||||
// unhandled on some devices (issue #23 review).
|
||||
val isUp = ke.key == Key.DirectionUp || code == KeyEvent.KEYCODE_DPAD_UP
|
||||
val isDown = ke.key == Key.DirectionDown || code == KeyEvent.KEYCODE_DPAD_DOWN
|
||||
val isLeft = ke.key == Key.DirectionLeft || code == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
val isCenter = ke.key == Key.DirectionCenter || ke.key == Key.Enter ||
|
||||
code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER
|
||||
when {
|
||||
// UP/DOWN are always consumed and always attempt the scroll (it
|
||||
// clamps at the ends by itself), so focus stays here and the text
|
||||
// can always be scrolled back up.
|
||||
isDown -> {
|
||||
scope.launch { scroll.animateScrollBy(step) }
|
||||
true
|
||||
}
|
||||
isUp -> {
|
||||
scope.launch { scroll.animateScrollBy(-step) }
|
||||
true
|
||||
}
|
||||
isLeft -> {
|
||||
onDismiss(); true
|
||||
}
|
||||
isCenter -> {
|
||||
onActivate(); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.verticalScroll(scroll)
|
||||
.onSizeChanged { viewportPx = it.height },
|
||||
) {
|
||||
Text(
|
||||
text = annotated,
|
||||
color = TextMain,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (links.isNotEmpty()) {
|
||||
item(key = "linkshdr") {
|
||||
Text(
|
||||
text = "Links",
|
||||
color = TextDim,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 10.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
links.forEachIndexed { index, url ->
|
||||
item(key = "l" + index + url) {
|
||||
SidebarRow(
|
||||
onLeft = onDismiss,
|
||||
focusRequester = if (index == 0) firstLinkFocus else null,
|
||||
onClick = { open(url) },
|
||||
) {
|
||||
Text(
|
||||
text = linkLabel(url),
|
||||
color = TextMain,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Link click listener: the annotation callback shape. */
|
||||
private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package net.jeena.jtubetv.ui.screens
|
|||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -37,6 +39,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
|
|
@ -63,19 +66,18 @@ import net.jeena.jtubetv.data.api.ChannelStats
|
|||
import net.jeena.jtubetv.data.api.UserDto
|
||||
import net.jeena.jtubetv.data.api.VideoChannelDto
|
||||
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.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.dpadCenterActivatable
|
||||
|
||||
/**
|
||||
* Channels, SmartTube-style: one row per LOCAL user.
|
||||
*
|
||||
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local
|
||||
* users sorted by videos / subscribers / plays (desc).
|
||||
* - Each row: that user's channels, same ordering; channels with no videos simply
|
||||
* stay inside their user's row.
|
||||
* - Row 1: the logged-in user ("<display name>'s Channels") — only when they own
|
||||
* at least one channel WITH videos; then the other local users by total videos.
|
||||
* - Channels with no videos never render (watching-only accounts), at any level.
|
||||
* - Followed-instance channels at the end, grouped per platform.
|
||||
*
|
||||
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
|
||||
|
|
@ -93,10 +95,6 @@ data class UserGroup(
|
|||
|
||||
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||
|
||||
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)
|
||||
private set
|
||||
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
|
|
@ -126,26 +124,21 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
|
||||
private var lastLoadedAt = 0L
|
||||
|
||||
init {
|
||||
// Snapshot restore (issue #9): paint the last session's channel rows
|
||||
// instantly, including the per-channel stats (the "minutes of loading"
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
// No snapshot instant-paint any more (issue #25): the Channels screen shows
|
||||
// its shimmer placeholder rows until the (fast-path, few-request) load lands,
|
||||
// instead of painting a stale snapshot that reshuffled as stats streamed in.
|
||||
|
||||
/** Guards against a second pass while the first is still running (issue #32:
|
||||
* the load now starts at app launch, so opening the view can overlap it). */
|
||||
private var inFlight = false
|
||||
|
||||
fun load(force: Boolean = false) {
|
||||
// Re-entry (destination switch away + back) inside the freshness window:
|
||||
// repaint the in-memory rows and skip the network (issue #13) — rows are
|
||||
// never torn down on re-entry; an account switch builds a new ViewModel.
|
||||
if (inFlight) return
|
||||
if (!force && userRows.isNotEmpty() && fresh()) return
|
||||
inFlight = true
|
||||
loading = true
|
||||
error = null
|
||||
viewModelScope.launch {
|
||||
|
|
@ -174,6 +167,7 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
error = e.message ?: "Could not load channels"
|
||||
} finally {
|
||||
loading = false
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -224,11 +218,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
stats = statsMap.toMap()
|
||||
rebuildRows(host)
|
||||
lastLoadedAt = android.os.SystemClock.elapsedRealtime()
|
||||
snapshotContext?.let { ctx ->
|
||||
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap())
|
||||
}
|
||||
}
|
||||
ImagePrefetcher.channelArt(allChannels + userChannels)
|
||||
}
|
||||
|
||||
|
|
@ -280,14 +269,24 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
}
|
||||
|
||||
private fun rebuildRowsInner(host: String?) {
|
||||
val locals = allChannels.filter { it.host == host }
|
||||
// Channels without videos don't exist on this screen: plenty of local
|
||||
// accounts are watching-only and their empty channels are pure noise if
|
||||
// rendered (user rule). This also covers the own row — it only renders
|
||||
// when the logged-in user has at least one channel with videos, and the
|
||||
// group sort below still puts it first in that case.
|
||||
val locals = allChannels
|
||||
.filter { it.host == host && statOf(it) > 0 }
|
||||
val ownNames = userChannels.map { c -> c.name }.toSet()
|
||||
val byOwner = locals.groupBy { ownerKey(it) }
|
||||
val groups = byOwner.values.map { row ->
|
||||
val sorted = row.sortedWith(
|
||||
// Stable keys only: all of these come from the video listing and
|
||||
// never change mid-load — subscriber counts arrive later per-
|
||||
// channel and must not reorder anything while loading (rows that
|
||||
// jump while stats stream in read as broken).
|
||||
compareByDescending<VideoChannelDto> { statOf(it) }
|
||||
.thenByDescending { it.followersCount }
|
||||
.thenByDescending { playsOf(it) },
|
||||
.thenByDescending { playsOf(it) }
|
||||
.thenByDescending { it.followersCount },
|
||||
)
|
||||
val own = row.firstOrNull { it.name in ownNames }
|
||||
UserGroup(
|
||||
|
|
@ -300,14 +299,9 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
userRows = groups.sortedWith(
|
||||
compareByDescending<UserGroup> { g -> g.channels.any { it.name in ownNames } }
|
||||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } }
|
||||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.plays ?: 0 } }
|
||||
.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(
|
||||
"JTube",
|
||||
"order now: " + userRows.take(6).joinToString(" | ") { g ->
|
||||
|
|
@ -335,6 +329,8 @@ fun ChannelsScreen(
|
|||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
drawerPulse: Int = 0,
|
||||
/** Channel opened last — gets the focus back on return (issue #34). */
|
||||
focusHandle: String? = null,
|
||||
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||
) {
|
||||
LaunchedEffect(epoch) { viewModel.load() }
|
||||
|
|
@ -342,10 +338,8 @@ fun ChannelsScreen(
|
|||
|
||||
when {
|
||||
viewModel.loading && viewModel.userRows.isEmpty() ->
|
||||
// Placeholder rows while the channels load (issue #19): header stub
|
||||
// + a row of shimmer card-shaped placeholders, twice.
|
||||
// One row header stub + a single loading card, not a fake grid (#31).
|
||||
androidx.compose.foundation.layout.Column {
|
||||
repeat(2) {
|
||||
Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
|
|
@ -355,8 +349,7 @@ fun ChannelsScreen(
|
|||
.background(Color(0xFF232323)),
|
||||
)
|
||||
}
|
||||
ShimmerRow()
|
||||
}
|
||||
ShimmerRow(size = 1)
|
||||
}
|
||||
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
|
|
@ -377,6 +370,14 @@ fun ChannelsScreen(
|
|||
// as stats stream in the rows re-sort, and re-focusing their first card
|
||||
// would steal focus mid-navigation (user: "up jumps to the first row").
|
||||
val focusHandedOut = remember { mutableStateOf(false) }
|
||||
// The channel we came back from only wins the focus while it is still
|
||||
// in the rows. When it isn't (renamed, unsubscribed, or the rows simply
|
||||
// never carried it) every row used to ask for no focus at all — "when I
|
||||
// click on Channels, none is selected" (#32 review). Fall back to the
|
||||
// first row in that case, like the video cards do.
|
||||
val focusRowExists = focusHandle != null && viewModel.userRows.any {
|
||||
group -> group.channels.any { it.name + "@" + it.host == focusHandle }
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
viewModel.userRows.forEach { group ->
|
||||
item(key = "hdr-" + group.ownerName) {
|
||||
|
|
@ -386,13 +387,22 @@ fun ChannelsScreen(
|
|||
)
|
||||
}
|
||||
item(key = "row-" + group.ownerName) {
|
||||
// Coming back from a channel page, focus the card we opened
|
||||
// instead of the very first one (issue #34).
|
||||
val target = group.channels.indexOfFirst {
|
||||
it.name + "@" + it.host == focusHandle
|
||||
}
|
||||
val isTargetRow = target >= 0
|
||||
ChannelRow(
|
||||
group.channels,
|
||||
onOpenChannel,
|
||||
onOpenDrawer,
|
||||
drawerPulse = drawerPulse,
|
||||
focused = !focusHandedOut.value &&
|
||||
group == viewModel.userRows.firstOrNull(),
|
||||
focused = !focusHandedOut.value && (
|
||||
if (focusRowExists) isTargetRow
|
||||
else group == viewModel.userRows.firstOrNull()
|
||||
),
|
||||
focusIndex = if (isTargetRow) target else 0,
|
||||
onFocused = { focusHandedOut.value = true },
|
||||
stats = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformName = { viewModel.platformNames[it.host ?: ""] },
|
||||
|
|
@ -439,6 +449,7 @@ private fun absoluteAvatar(avatar: Avatar): String =
|
|||
}
|
||||
|
||||
/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channels: List<VideoChannelDto>,
|
||||
|
|
@ -446,6 +457,8 @@ private fun ChannelRow(
|
|||
onOpenDrawer: () -> Unit = {},
|
||||
drawerPulse: Int = 0,
|
||||
focused: Boolean,
|
||||
/** Which card to hand the initial focus to (issue #34). */
|
||||
focusIndex: Int = 0,
|
||||
onFocused: () -> Unit = {},
|
||||
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||
platformName: (VideoChannelDto) -> String?,
|
||||
|
|
@ -468,19 +481,39 @@ private fun ChannelRow(
|
|||
val notFullyVisible = target == null ||
|
||||
target.offset < 0 ||
|
||||
target.offset + target.size > rowState.layoutInfo.viewportEndOffset
|
||||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
if (focused) {
|
||||
LaunchedEffect(channels.isNotEmpty()) {
|
||||
LaunchedEffect(channels.isNotEmpty(), focusIndex) {
|
||||
if (channels.isNotEmpty()) {
|
||||
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
|
||||
val target = focusIndex.coerceIn(0, channels.size - 1)
|
||||
// Bring it on screen first; a card further right isn't composed yet,
|
||||
// so retry until its focus requester is attached (issue #34).
|
||||
if (target > 0) runCatching { rowState.scrollToItem(target) }
|
||||
repeat(8) {
|
||||
delay(80)
|
||||
val ok = runCatching { focusRequesters[target]?.requestFocus() }.getOrNull()
|
||||
if (ok != null) {
|
||||
onFocused()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
// The wanted card never became focusable (its row scrolled past it,
|
||||
// or the handle went stale): take the first card instead, so the
|
||||
// screen is never left with nothing selected (#32 review).
|
||||
val first = focusRequesters[0]
|
||||
if (first != null && runCatching { first.requestFocus() }.isSuccess) onFocused()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
// Land on the last-focused card when moving between rows (issue #29). No
|
||||
// custom fallback: a requester whose card has scrolled out of composition
|
||||
// crashed with "FocusRequester is not initialized".
|
||||
modifier = Modifier.focusRestorer(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
|
|
@ -553,13 +586,19 @@ fun ChannelCard(
|
|||
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||
?: stats?.latestThumbUrl
|
||||
if (coverUrl != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
// The request must be remembered: ImageRequest has no value equality,
|
||||
// a fresh request per recomposition reset the shimmer (and retried
|
||||
// the load) every frame — the fps regression on the TV.
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val coverKey = remember(channel.name, channel.host) { channel.name + "@" + channel.host }
|
||||
val coverReq = remember(coverKey, coverUrl) {
|
||||
coil.request.ImageRequest.Builder(ctx)
|
||||
.data(coverUrl)
|
||||
.size(480, 270) // same key as prefetch
|
||||
.build(),
|
||||
.build()
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = coverReq,
|
||||
contentDescription = channel.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
||||
|
|
@ -567,21 +606,26 @@ fun ChannelCard(
|
|||
}
|
||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val avatarUrl = remember(avatar.path, avatar.fileUrl) { absoluteAvatar(avatar) }
|
||||
val avatarReq = remember(avatarUrl) {
|
||||
coil.request.ImageRequest.Builder(
|
||||
ctx,
|
||||
)
|
||||
.data(absoluteAvatar(avatar))
|
||||
.data(avatarUrl)
|
||||
.size(80, 80)
|
||||
.build(),
|
||||
.build()
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = avatarReq,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF333333)),
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package net.jeena.jtubetv.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -9,11 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -40,7 +44,6 @@ import net.jeena.jtubetv.AppGraph
|
|||
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||
import net.jeena.jtubetv.data.api.VideoDto
|
||||
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.VideoCard
|
||||
|
||||
|
|
@ -57,8 +60,6 @@ data class ContinueItem(val video: VideoDto, val progress: Float?)
|
|||
|
||||
class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() {
|
||||
|
||||
private fun contextOrNull() = AppGraph.appContext
|
||||
|
||||
var continueItems by mutableStateOf<List<ContinueItem>>(emptyList())
|
||||
private set
|
||||
var continueLoading by mutableStateOf(true)
|
||||
|
|
@ -75,36 +76,24 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
private var continueInFlight = false
|
||||
var continueExhausted by mutableStateOf(false)
|
||||
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 subsInFlight = false
|
||||
var subsExhausted by mutableStateOf(false)
|
||||
private set
|
||||
var subsLoadingMore by mutableStateOf(false)
|
||||
private set
|
||||
/** Exposed for the shimmer placeholders (issue #19). */
|
||||
var subsLoading = false
|
||||
private set
|
||||
|
||||
val recent = RecentVideosViewModel(repository)
|
||||
|
||||
init {
|
||||
// Snapshot restore (issue #9): Home paints from the last session's rows
|
||||
// immediately; the loads below freshen them from the network.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No snapshot instant-paint any more (issue #25): Home shows one loading card
|
||||
// per row until the network pages land, instead of painting a stale snapshot
|
||||
// that then jumped as newer videos arrived.
|
||||
|
||||
private fun fetchSubs() {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -115,12 +104,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
subItems = subs
|
||||
subsLoading = false
|
||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs)
|
||||
// Snapshot for the next cold start (issue #9).
|
||||
contextOrNull()?.let { ctx ->
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
Snapshots.saveVideos(ctx, "home-subs", subs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +159,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
fun continueMore() {
|
||||
if (continueInFlight || continueExhausted || continueItems.isEmpty()) return
|
||||
continueInFlight = true
|
||||
continueLoadingMore = true
|
||||
viewModelScope.launch {
|
||||
val page = fetchContinuePage()
|
||||
if (page.isNotEmpty()) {
|
||||
|
|
@ -183,6 +167,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
|
||||
}
|
||||
continueInFlight = false
|
||||
continueLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +175,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
fun subsMore() {
|
||||
if (subsInFlight || subsExhausted || subItems.isEmpty()) return
|
||||
subsInFlight = true
|
||||
subsLoadingMore = true
|
||||
viewModelScope.launch {
|
||||
val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList()
|
||||
subsStart += page.size
|
||||
|
|
@ -199,6 +185,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
|
||||
}
|
||||
subsInFlight = false
|
||||
subsLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,12 +197,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
continueItems = items
|
||||
continueLoading = false
|
||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video })
|
||||
// Snapshot for the next cold start (issue #9).
|
||||
contextOrNull()?.let { ctx ->
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
Snapshots.saveContinue(ctx, "home-continue", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,28 +216,8 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
/** Paged "New videos" row content. */
|
||||
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>) {
|
||||
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>> =
|
||||
|
|
@ -314,9 +275,10 @@ fun HomeRowsScreen(
|
|||
}
|
||||
item {
|
||||
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.
|
||||
ShimmerRow()
|
||||
ShimmerRow(size = 1)
|
||||
} else if (viewModel.continueItems.isEmpty()) {
|
||||
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
|
||||
Text(
|
||||
|
|
@ -338,6 +300,7 @@ fun HomeRowsScreen(
|
|||
autoFocusOnLoad = true,
|
||||
autoFocusAllowed = { !userNavigated },
|
||||
refocusPulse = drawerPulse,
|
||||
trailingLoading = viewModel.continueLoadingMore,
|
||||
) { index, video ->
|
||||
// Grow the row on demand (issue #18), same hook as the recent row.
|
||||
if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
|
|
@ -357,7 +320,7 @@ fun HomeRowsScreen(
|
|||
item {
|
||||
if (recentItems.isEmpty()) {
|
||||
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
|
||||
ShimmerRow()
|
||||
ShimmerRow(size = 1)
|
||||
} else {
|
||||
FocusScrollRow(
|
||||
videos = recentItems,
|
||||
|
|
@ -367,6 +330,7 @@ fun HomeRowsScreen(
|
|||
clickedIndex = recentRestore,
|
||||
restoreToken = restoreToken,
|
||||
refocusPulse = drawerPulse,
|
||||
trailingLoading = viewModel.recent.loadingMore,
|
||||
) { index, video ->
|
||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||
|
|
@ -385,7 +349,7 @@ fun HomeRowsScreen(
|
|||
}
|
||||
item {
|
||||
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
|
||||
ShimmerRow()
|
||||
ShimmerRow(size = 1)
|
||||
} else {
|
||||
FocusScrollRow(
|
||||
videos = viewModel.subItems,
|
||||
|
|
@ -395,6 +359,7 @@ fun HomeRowsScreen(
|
|||
clickedIndex = subRestore,
|
||||
restoreToken = restoreToken,
|
||||
refocusPulse = drawerPulse,
|
||||
trailingLoading = viewModel.subsLoadingMore,
|
||||
) { index, video ->
|
||||
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.subsMore() }
|
||||
|
|
@ -424,6 +389,7 @@ fun ShimmerRow(size: Int = 4) {
|
|||
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of
|
||||
* the abrupt jump the built-in lazy scroll produces.
|
||||
*/
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun FocusScrollRow(
|
||||
videos: List<VideoDto>,
|
||||
|
|
@ -442,6 +408,9 @@ fun FocusScrollRow(
|
|||
// NOTE: stays before [afterItem] — the trailing lambda binds to the last
|
||||
// declared parameter.
|
||||
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 = { _, _ -> },
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
|
@ -484,11 +453,17 @@ fun FocusScrollRow(
|
|||
val notFullyVisible = target == null ||
|
||||
target.offset < 0 ||
|
||||
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
||||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
if (notFullyVisible) rowState.easedScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
// Jumping between rows lands on the card this row last had focus on, not
|
||||
// the geometrically-nearest one (issue #29): the rows stop shifting
|
||||
// left/right as you move up and down between them. No custom fallback —
|
||||
// handing back a requester whose card has scrolled out of composition
|
||||
// crashed with "FocusRequester is not initialized".
|
||||
modifier = Modifier.focusRestorer(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
|
|
@ -512,5 +487,30 @@ fun FocusScrollRow(
|
|||
)
|
||||
afterItem(index, video)
|
||||
}
|
||||
if (trailingLoading) {
|
||||
item(key = "trailing-loading") {
|
||||
ShimmerCard(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.width(rowCardWidth()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eased scroll to [index] with a capped duration (perf): [animateScrollToItem]'s
|
||||
* default spec keeps re-ticking for hundreds of ms on long distances, and every
|
||||
* tick is a full-window redraw on a TV GPU — which is what dragged the scroll
|
||||
* down to a few fps. A short tween keeps the eased feel at a fraction of the
|
||||
* ticks. (Shared by the home rows and the channels rows.)
|
||||
*/
|
||||
suspend fun LazyListState.easedScrollToItem(index: Int) {
|
||||
val info = layoutInfo
|
||||
val first = info.visibleItemsInfo.firstOrNull() ?: return
|
||||
val stride = first.size + info.mainAxisItemSpacing
|
||||
if (stride <= 0) return
|
||||
val delta = index * stride - (first.index * stride + firstVisibleItemScrollOffset)
|
||||
animateScrollBy(delta.toFloat(), tween(140))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,22 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
||||
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
|
||||
var channelName by rememberSaveable { mutableStateOf<String?>(null) } // display name
|
||||
// Last channel opened from the Channels screen — refocused on return (#34).
|
||||
var lastOpenedChannel by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Channels are expensive to assemble, so start that work at app launch rather
|
||||
// than when the view is first opened (issue #32); the view then usually finds
|
||||
// the rows already there, and load() no-ops while fresh.
|
||||
val channelsViewModel: ChannelsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
|
||||
key = "channels$epoch",
|
||||
factory = ChannelsViewModel.factory,
|
||||
)
|
||||
LaunchedEffect(epoch) { channelsViewModel.load() }
|
||||
// NOT saveable: launching the app should never resume a half-watched player session.
|
||||
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
|
||||
// Non-null while the playing video lives on ANOTHER instance (vidiverse results).
|
||||
var playingVideoHost by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Thumbnail of the opened card — shown as the player's poster while it loads.
|
||||
var playingVideoThumb by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
|
||||
var drawerOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
|
@ -114,19 +126,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
}
|
||||
}
|
||||
|
||||
// BACK order (TV standard): player (own handler) → drawer close → channel
|
||||
// drilldown → non-Home destination → Home; from Home: double-press exits.
|
||||
// BACK order (issue #26): the sidebar is the root of a list view, reached
|
||||
// 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) {
|
||||
dismissDrawer()
|
||||
}
|
||||
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 {
|
||||
if (destination == Destination.Home && channelDetails == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
|
|
@ -134,8 +142,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
}
|
||||
lastBackAt = now
|
||||
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(
|
||||
modifier = Modifier
|
||||
|
|
@ -151,7 +167,9 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
when {
|
||||
drawerOpen -> { dismissDrawer(); 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
|
||||
},
|
||||
|
|
@ -169,6 +187,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
val localHost = AppGraph.tokenStore.baseUrl?.substringAfter("://")?.substringBefore('/')
|
||||
val host = video.channel.host
|
||||
playingVideoHost = if (host != null && host != localHost) host else null
|
||||
playingVideoThumb = video.thumbnailUrl
|
||||
?: video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { AppGraph.repository.absoluteUrl(it) }
|
||||
playingVideo = video.shortUUID
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -210,10 +230,16 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
drawerPulse = drawerPulse,
|
||||
)
|
||||
Destination.Channels -> ChannelsScreen(
|
||||
onOpenChannel = { handle -> channelDetails = handle },
|
||||
onOpenChannel = { handle ->
|
||||
channelDetails = handle
|
||||
lastOpenedChannel = handle
|
||||
},
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
drawerPulse = drawerPulse,
|
||||
// Back from a channel page re-selects it (issue #34).
|
||||
focusHandle = lastOpenedChannel,
|
||||
viewModel = channelsViewModel,
|
||||
)
|
||||
Destination.Search ->
|
||||
VideoListScreen(
|
||||
|
|
@ -267,13 +293,15 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
PlayerScreen(
|
||||
shortUuid = playingVideo!!,
|
||||
sourceHost = playingVideoHost,
|
||||
posterUrl = playingVideoThumb,
|
||||
onBack = { playingVideo = null },
|
||||
onOpenChannel = { handle, displayName ->
|
||||
playingVideo = null
|
||||
channelDetails = handle
|
||||
channelName = displayName
|
||||
},
|
||||
onOpenVideo = { playingVideo = it; playingVideoHost = null },
|
||||
// A link-opened video has no card thumbnail to pose with.
|
||||
onOpenVideo = { playingVideo = it; playingVideoHost = null; playingVideoThumb = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ fun PlayerScreen(
|
|||
onBack: () -> Unit,
|
||||
/** Set when the video lives on another instance (vidiverse search results). */
|
||||
sourceHost: String? = null,
|
||||
/** Thumbnail of the opened card, shown full-screen while the metadata loads so
|
||||
* the player doesn't flash black before buffering starts (user report). */
|
||||
posterUrl: String? = null,
|
||||
// From the info sidebar's local PeerTube video links: switches the player over.
|
||||
onOpenVideo: (String) -> Unit = {},
|
||||
// From the paused/controls overlay's channel icon: (handle, displayName),
|
||||
|
|
@ -227,15 +230,27 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
when (val s = state) {
|
||||
is PlayerUiState.Loading -> PlayerLoading()
|
||||
is PlayerUiState.Loading -> PlayerLoading(posterUrl)
|
||||
is PlayerUiState.Error -> PlayerError(s.message, viewModel, onBack)
|
||||
is PlayerUiState.Ready -> PlayerSurface(s, viewModel, shortUuid, onBack, onOpenChannel, onOpenVideo)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerLoading() {
|
||||
private fun PlayerLoading(posterUrl: String? = null) {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
|
||||
// Show the card's thumbnail (dimmed) while the metadata resolves, so the
|
||||
// player opens onto the video's own poster instead of plain black; it
|
||||
// hands off seamlessly to the buffering thumbnail once the stream is ready.
|
||||
if (posterUrl != null) {
|
||||
coil.compose.AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color(0xB3000000)))
|
||||
}
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
|
@ -308,6 +323,10 @@ private fun PlayerSurface(
|
|||
// 200 ms: quick flaps cancel the pending change and the overlay stays
|
||||
// composed, so the animation never restarts mid-playback.
|
||||
var bufferingPending by remember { mutableStateOf(true) }
|
||||
// STATE_READY can fire before the first frame is actually drawn, leaving a black
|
||||
// gap between the thumbnail and the picture. Keep the thumbnail until the first
|
||||
// frame really renders (user report: "thumbnail, then black, then playing").
|
||||
var firstFrame by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(bufferingPending) {
|
||||
kotlinx.coroutines.delay(200)
|
||||
buffering = bufferingPending
|
||||
|
|
@ -419,7 +438,14 @@ private fun PlayerSurface(
|
|||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
bufferingPending = playbackState != Player.STATE_READY
|
||||
// Only real buffering shows the overlay (issue #33): STATE_ENDED used
|
||||
// to count as "not ready" and brought the thumbnail + spinner back
|
||||
// over the last frame. The initial load is covered by !firstFrame.
|
||||
bufferingPending = playbackState == Player.STATE_BUFFERING
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
firstFrame = true
|
||||
}
|
||||
})
|
||||
onDispose {
|
||||
|
|
@ -573,7 +599,7 @@ private fun PlayerSurface(
|
|||
// ready to render — metadata resolution alone (the Loading state) is not
|
||||
// enough (issue #8). Fullscreen loading shows the video's own thumbnail
|
||||
// dimmed under the circle loader (issue #19) instead of plain black.
|
||||
if (buffering) {
|
||||
if (buffering || !firstFrame) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -620,9 +646,9 @@ private fun PlayerSurface(
|
|||
closeSidebar()
|
||||
},
|
||||
onOpenVideo = onOpenVideo,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(top = 48.dp),
|
||||
// Full height from the very top — the old top padding left a black
|
||||
// strip above the panel (issues #23/#24).
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,12 +124,19 @@ fun VideoCard(
|
|||
.background(Color(0xFF222222)),
|
||||
) {
|
||||
video.thumbnailPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
val req = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||
// The request object must be remembered: ImageRequest has no value
|
||||
// equality, and a fresh request per recomposition reset the
|
||||
// shimmer's loaded state (and retried the load) on every frame —
|
||||
// the fps regression on the TV.
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val req = remember(video.id, video.thumbnailUrl) {
|
||||
coil.request.ImageRequest.Builder(ctx)
|
||||
// search-index results carry an absolute URL for their own instance
|
||||
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
||||
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||
.build()
|
||||
AsyncImage(
|
||||
}
|
||||
ShimmerAsyncImage(
|
||||
model = req,
|
||||
contentDescription = video.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -320,6 +327,48 @@ fun ShimmerCard(modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coil image that shows the same faint shimmer as [ShimmerCard] while it loads
|
||||
* (issue #28) — thumbnails, channel banners and avatars no longer pop in from a
|
||||
* flat grey rectangle. The sweep runs only while loading: once the image (or an
|
||||
* error) arrives, the overlay leaves composition and its animation disposes.
|
||||
*/
|
||||
@Composable
|
||||
fun ShimmerAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
base: Color = Color(0xFF222222),
|
||||
) {
|
||||
var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
Box(modifier.background(base)) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
onState = { state ->
|
||||
loaded = state is coil.compose.AsyncImagePainter.State.Success ||
|
||||
state is coil.compose.AsyncImagePainter.State.Error
|
||||
},
|
||||
)
|
||||
if (!loaded) ShimmerFill(Modifier.matchParentSize(), base)
|
||||
}
|
||||
}
|
||||
|
||||
/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */
|
||||
@Composable
|
||||
private fun ShimmerFill(modifier: Modifier, base: Color) {
|
||||
val sweep = rememberInfiniteTransition()
|
||||
val x by sweep.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)),
|
||||
)
|
||||
Box(modifier.background(base).shimmer(x))
|
||||
}
|
||||
|
||||
/** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */
|
||||
fun Modifier.shimmer(progress: Float): Modifier = drawBehind {
|
||||
drawRect(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package net.jeena.jtubetv.ui.screens.list
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -70,10 +74,42 @@ fun VideoListScreen(
|
|||
),
|
||||
) {
|
||||
val items = viewModel.items
|
||||
// per-item focus requesters so BACK from the player can refocus the opened card
|
||||
val requesters = androidx.compose.runtime.remember(items.size, epoch) {
|
||||
androidx.compose.runtime.mutableStateMapOf<Int, FocusRequester>()
|
||||
// per-item focus requesters so BACK from the player can refocus the opened card.
|
||||
// A plain HashMap, like the rows: this is/was written during composition of
|
||||
// every card, and a snapshot map here re-invalidated the reading scopes on
|
||||
// each write — the exact recompose loop behind the old 2fps scroll (5ea1ca5).
|
||||
// Keyed only on [epoch]: on items.size the map was dropped wholesale, and
|
||||
// already-composed cards didn't re-populate it until their next recompose.
|
||||
val requesters = androidx.compose.runtime.remember(epoch) {
|
||||
HashMap<Int, FocusRequester>()
|
||||
}
|
||||
// Scroll position survives destination switches (per source), not just player exits.
|
||||
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
|
||||
inputs = arrayOf(epoch),
|
||||
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
|
||||
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
|
||||
|
||||
if (source is ListSource.Channel) {
|
||||
// Fetch the banner/avatar/counts for the channel page header.
|
||||
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
|
||||
// Park the grid at the top as soon as the page has content, BEFORE the
|
||||
// first card takes focus (the effect below). It used to run after it:
|
||||
// handing the first card the focus animates the grid up (a focused card
|
||||
// that sits partly off-screen is pulled into view), and this effect then
|
||||
// snapped it back down — the jump that was still reported on the channel
|
||||
// page when the first video got focus (#27 review).
|
||||
// The park has to be unconditional, and has to reset the row's offset,
|
||||
// not just its index: the focus pull leaves the header row at index 0 but
|
||||
// slid up by ~96 dp, so a guard on firstVisibleItemIndex > 0 never fires
|
||||
// in that state and the top of the channel header stays clipped off
|
||||
// screen (#31 review). scrollToItem(0) re-anchors both.
|
||||
LaunchedEffect(items.isNotEmpty(), source) {
|
||||
if (items.isNotEmpty()) {
|
||||
runCatching { gridState.scrollToItem(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull initial focus out of the sidebar rail and into the content grid.
|
||||
val initialFocus = remember { FocusRequester() }
|
||||
// Which card was opened last — re-focus it when playback ends (UX report BUG-3).
|
||||
|
|
@ -82,6 +118,9 @@ fun VideoListScreen(
|
|||
// With a running search the first RESULT gets focus; without one the
|
||||
// field does (the SearchHeader's own effect) — never both (issue #20).
|
||||
if (items.isNotEmpty() && (source !is ListSource.Search || viewModel.searchActive)) {
|
||||
// The channel page parks its grid at the top just above; wait for that
|
||||
// beat so the focus handover can never leave a scroll to undo (#27).
|
||||
if (source is ListSource.Channel) kotlinx.coroutines.delay(250)
|
||||
runCatching { requesters[0]?.requestFocus() ?: initialFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
|
@ -96,17 +135,6 @@ fun VideoListScreen(
|
|||
lastOpenedIndex = -1
|
||||
}
|
||||
}
|
||||
// Scroll position survives destination switches (per source), not just player exits.
|
||||
val gridState = androidx.compose.runtime.saveable.rememberSaveable(
|
||||
inputs = arrayOf(epoch),
|
||||
key = "g$epoch:" + sourceKey(source), saver = androidx.compose.foundation.lazy.grid.LazyGridState.Saver,
|
||||
) { androidx.compose.foundation.lazy.grid.LazyGridState() }
|
||||
|
||||
if (source is ListSource.Channel) {
|
||||
// Fetch the banner/avatar/counts for the channel page header.
|
||||
LaunchedEffect(source) { viewModel.loadChannelHeader(AppGraph.repository) }
|
||||
}
|
||||
|
||||
// Row starts of the grid: LEFT from a row's first card opens the drawer
|
||||
// (issue #7). The adaptive columns use a `rowCardWidth() - 12` minimum with
|
||||
// zero spacing, and the content pane eats 40dp (8 start + 32 end), so the
|
||||
|
|
@ -165,18 +193,23 @@ fun VideoListScreen(
|
|||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||
) {
|
||||
// Page headers (search field / channel banner) live INSIDE the grid so
|
||||
// they scroll with the content and never stack under the cards.
|
||||
// Page headers scroll WITH the content (issue #27 review: a pinned header
|
||||
// stayed put and the videos slid behind it). The header is compact enough
|
||||
// that it's still fully visible when the first card takes focus.
|
||||
if (source is ListSource.Search || source is ListSource.Channel) {
|
||||
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
when (source) {
|
||||
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
|
||||
is ListSource.Channel -> ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
handle = source.handle,
|
||||
onLeftEdge = onOpenDrawer,
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
||||
item(key = "search-empty", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
// Issue #20: an empty search stays usable — the field above keeps
|
||||
|
|
@ -199,23 +232,16 @@ fun VideoListScreen(
|
|||
// Placeholder cards while the page's first chunk loads (issue #19):
|
||||
// shaped like the real cards, with the faint shimmer, on every
|
||||
// VideoListScreen (My videos, Search, Subscriptions, History, channel pages).
|
||||
item(key = "initial-placeholders", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
item(key = "initial-placeholders") {
|
||||
LaunchedEffect(source) { viewModel.loadMore(force = true) }
|
||||
androidx.compose.foundation.layout.Column {
|
||||
repeat(2) {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
repeat(cardsPerRow) {
|
||||
// A single loading card, not a full fake grid (issue #31).
|
||||
ShimmerCard(
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.width(rowCardWidth()),
|
||||
.width(rowCardWidth() - 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item ->
|
||||
val video: VideoDto = when (item) {
|
||||
is VideoListItem.HistoryEntry -> item.history.video
|
||||
|
|
@ -239,22 +265,18 @@ fun VideoListScreen(
|
|||
}
|
||||
}
|
||||
if (viewModel.loadingMore) {
|
||||
// Growing the list: placeholder row (issue #19) instead of the
|
||||
// permanent spinner chip that used to sit under every list.
|
||||
item(key = "load-more-placeholders") {
|
||||
androidx.compose.foundation.layout.Row {
|
||||
repeat(cardsPerRow) {
|
||||
// Growing the list: a single loading card at the end (#25) so it's
|
||||
// clear more is coming, without faking a whole row of content.
|
||||
item(key = "load-more-placeholder") {
|
||||
ShimmerCard(
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.width(rowCardWidth()),
|
||||
.width(rowCardWidth() - 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchHeader(viewModel: VideoListViewModel, onOpenDrawer: () -> Unit = {}) {
|
||||
|
|
@ -396,62 +418,135 @@ private fun ScopeChip(
|
|||
}
|
||||
}
|
||||
|
||||
/** Channel page header: banner strip, then avatar + display name + handle,
|
||||
* a metadata strip (subscribers/videos/created + owner, issue #14) and the
|
||||
* channel description snippet above the videos. */
|
||||
/**
|
||||
* Channel page header (issue #27): the banner is the backdrop of a fixed-height
|
||||
* header, darkened with a bottom-up gradient, with the channel identity + stats
|
||||
* and a reachable Subscribe button laid over it. It scrolls with the video grid.
|
||||
*
|
||||
* Its height is the same before and after the channel loads, so the cards never
|
||||
* get pushed down when it arrives (issue #27 review), and a channel with no
|
||||
* banner gets a colour gradient derived from its handle instead of flat black.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
|
||||
videoTotal: Long = 0,
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit = {},
|
||||
) {
|
||||
val metaColor = Color(0xFFCFCFCF)
|
||||
val banner = channelHeader?.banners?.maxByOrNull { it.width }
|
||||
val description = channelHeader?.description?.takeIf { it.isNotBlank() }
|
||||
val accent = remember(handle) { channelAccent(handle) }
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Banner area: 116 dp, with the identity + stats anchored to its bottom; the
|
||||
// description sits in the black strip below it. The whole band is 160 dp
|
||||
// (4 + 116 + 40) — under one card row — so the grid never reflows around it
|
||||
// and the band stays fully on screen when the first card takes focus (#31).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(116.dp)
|
||||
.background(androidx.compose.ui.graphics.Brush.linearGradient(accent)),
|
||||
) {
|
||||
if (channelHeader == null) return
|
||||
val metaColor = Color(0xFF9A9A9A)
|
||||
androidx.compose.foundation.layout.Column {
|
||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||
if (banner != null) {
|
||||
AsyncImage(
|
||||
ShimmerAsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
// Darkening overlay, fading to solid black at the bottom so the banner
|
||||
// blends seamlessly into the description strip beneath it.
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color(0x33000000),
|
||||
0.5f to Color(0x99000000),
|
||||
1f to Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
||||
// did before the redesign — user request). Only once the channel loaded;
|
||||
// the box above keeps the height reserved in the meantime.
|
||||
if (channelHeader != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
ShimmerAsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 10.dp)
|
||||
.size(52.dp)
|
||||
.size(56.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||
)
|
||||
} else {
|
||||
// A channel with no icon gets a stand-in, not a gap (#27 review).
|
||||
Box(
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color(0x40FFFFFF)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Icons.Filled.AccountCircle,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE8E8E8),
|
||||
modifier = Modifier.size(42.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||
Text(
|
||||
text = channelHeader.displayName,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
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 = channelHeader.name + "@" + channelHeader.host,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = byline,
|
||||
color = Color(0xFFBDBDBD),
|
||||
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(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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)
|
||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||
|
|
@ -462,31 +557,121 @@ private fun ChannelHeader(
|
|||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||
}
|
||||
}
|
||||
val owner = channelHeader.ownerAccount
|
||||
if (owner != null) {
|
||||
val host = channelHeader.host
|
||||
val platform = AppGraph.tokenStore.instanceName ?: host
|
||||
Text(
|
||||
text = "Owned by " + (owner.displayName ?: owner.name) +
|
||||
(if (platform != null) " - " + platform else ""),
|
||||
color = metaColor,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
}
|
||||
// Subscribe button, top-right over the banner; LEFT opens the sidebar so
|
||||
// the header is never a dead end (issue #27).
|
||||
SubscribeButton(
|
||||
handle = handle,
|
||||
onLeftEdge = onLeftEdge,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
}
|
||||
// Description strip in the black beneath the banner. Fixed height so the
|
||||
// header measures the same before and after the channel loads (#27 review).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 6.dp),
|
||||
) {
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
color = Color(0xFFB8B8B8),
|
||||
color = Color(0xFFCCCCCC),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
maxLines = 2,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic backdrop colours for a channel without a banner (#27 review):
|
||||
* the handle's hash picks a hue, so every channel keeps the same recognisable
|
||||
* gradient instead of a flat black header.
|
||||
*/
|
||||
private fun channelAccent(handle: String): List<Color> {
|
||||
val hue = ((handle.hashCode() ushr 8) % 360 + 360) % 360
|
||||
return listOf(
|
||||
Color.hsv(hue.toFloat(), 0.45f, 0.42f),
|
||||
Color.hsv(((hue + 38) % 360).toFloat(), 0.55f, 0.16f),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
|
||||
* player's action: the server's follow-exists check can lag for federated
|
||||
* channels, so a successful toggle trusts the user's action and re-syncs on the
|
||||
* next screen entry. One request at a time ([busy]).
|
||||
*/
|
||||
@Composable
|
||||
private fun SubscribeButton(
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = AppGraph.repository
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var subscribed by remember(handle) { mutableStateOf<Boolean?>(null) }
|
||||
var busy by remember(handle) { mutableStateOf(false) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(handle) {
|
||||
repository.subscriptionExists(handle).onSuccess { subscribed = it }
|
||||
}
|
||||
val isSub = subscribed == true
|
||||
val bg = when {
|
||||
focused -> Color.White
|
||||
isSub -> Color(0x33CE2B2B)
|
||||
else -> Color(0xFFCE2B2B)
|
||||
}
|
||||
val fg = if (focused) Color(0xFF101010) else Color.White
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
|
||||
.background(bg)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||
) {
|
||||
onLeftEdge(); true
|
||||
} else false
|
||||
}
|
||||
.dpadCenterActivatable {
|
||||
if (busy) return@dpadCenterActivatable
|
||||
busy = true
|
||||
scope.launch {
|
||||
val wasSub = subscribed == true
|
||||
val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
|
||||
else repository.subscribe(handle).isSuccess
|
||||
if (ok) subscribed = !wasSub
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = fg,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
|
||||
color = fg,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun headerStat(
|
||||
|
|
|
|||
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 |
BIN
docs/screenshots/channel.png
Normal file
|
After Width: | Height: | Size: 878 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 908 KiB |
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 689 KiB |
|
|
@ -15,6 +15,14 @@
|
|||
set -euo pipefail
|
||||
|
||||
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
||||
# Prefer the working host SDK at /opt/android-sdk: the emulator that
|
||||
# ~/Android/Sdk auto-updated to on Oct 11 (37.2.12) fails its own KVM probe
|
||||
# ("/dev/kvm is not found") with KVM perfectly healthy — /opt/android-sdk and
|
||||
# the container-home copy both pass -accel-check on the same kernel, so that
|
||||
# install is broken. /opt/android-sdk is the documented host SDK anyway.
|
||||
if [ -z "${ANDROID_SDK_ROOT:-}" ] && [ -x /opt/android-sdk/emulator/emulator ]; then
|
||||
SDK=/opt/android-sdk
|
||||
fi
|
||||
EMU="$SDK/emulator/emulator"
|
||||
ADB="$SDK/platform-tools/adb"
|
||||
SDKM="$SDK/cmdline-tools/latest/bin/sdkmanager"
|
||||
|
|
@ -76,6 +84,41 @@ if [ -e /dev/kvm ]; then
|
|||
echo "/dev/kvm found — hardware acceleration on"
|
||||
fi
|
||||
|
||||
# Pin the emulator process to the integrated GPU so the discrete card stays free
|
||||
# for AI inference (windowed mode uses the host GL/Vulkan stack; headless runs on
|
||||
# swiftshader — pure software — and never touches any GPU at all). Discovered
|
||||
# from the PCI tree: first display device NOT driven by the nvidia driver.
|
||||
IGPU=""
|
||||
for dev in /sys/bus/pci/devices/*; do
|
||||
class=$(cat "$dev/class" 2>/dev/null) || continue
|
||||
case "$class" in
|
||||
0x0300*|0x0380*) ;; # VGA / display controller
|
||||
*) continue ;;
|
||||
esac
|
||||
if [ "$(basename "$(readlink "$dev/driver" 2>/dev/null)")" = nvidia ]; then continue; fi
|
||||
IGPU=$(basename "$dev")
|
||||
break
|
||||
done
|
||||
if [ -n "$IGPU" ]; then
|
||||
# Mesa GL/GLES: force the Mesa vendors, then the Intel render device.
|
||||
export __GLX_VENDOR_LIBRARY_NAME=mesa
|
||||
export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json
|
||||
export DRI_PRIME="pci-${IGPU//./_}"
|
||||
# Vulkan: restrict the loader to the iGPU's ICD (both var names — loader
|
||||
# version differences).
|
||||
for icd in /usr/share/vulkan/icd.d/intel_icd.x86_64.json \
|
||||
/usr/share/vulkan/icd.d/Intel_icd.x86_64.json; do
|
||||
if [ -f "$icd" ]; then
|
||||
export VK_ICD_FILENAMES="$icd"
|
||||
export VK_DRIVER_FILES="$icd"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "GPU pinned to iGPU $IGPU (Mesa/Intel env) — dGPU untouched"
|
||||
else
|
||||
echo "no iGPU found — leaving GPU selection to the driver (dGPU may be used)"
|
||||
fi
|
||||
|
||||
# --- build combined adb vendor-keys file ------------------------------------
|
||||
# The TV system image is a user build: adb push to /data/misc/adb/adb_keys is
|
||||
# blocked, so remote keys are trusted via ADB_VENDOR_KEYS at launch instead.
|
||||
|
|
|
|||