Compare commits
No commits in common. "e9d7b473fb500cfc334c5d48112f667865d1e6de" and "9bdca8c98550eb21523473b30140846d89c88b25" have entirely different histories.
e9d7b473fb
...
9bdca8c985
|
|
@ -113,11 +113,10 @@ the workflow is strict:
|
|||
|
||||
## Scope discipline
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Backlog
|
||||
|
||||
Status vocabulary: `todo` · `doing` · `done`.
|
||||
|
||||
## v0.1 — MVP (implemented, pending device sign-off)
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation |
|
||||
| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too |
|
||||
| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 |
|
||||
| 3 | Search local videos | done | Own screen in sidebar |
|
||||
| 4 | Subscription videos | done | Verified incl. remote (federated) channels |
|
||||
| 5 | Watch history (server) | done | read-only grid |
|
||||
| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable |
|
||||
| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon |
|
||||
|
||||
## Discussed but deferred (2026-10-12, while reworking the player controls)
|
||||
|
||||
- Like/dislike display: counts are in the video detail DTO; rating (own like via
|
||||
`PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later.
|
||||
- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration`
|
||||
on the player; language selection in the controls overlay.
|
||||
|
||||
## Next up (v0.2+)
|
||||
|
||||
UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll
|
||||
memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus
|
||||
(tv-material Cards with scale/border), relative dates on cards, privacy badge only on
|
||||
My videos, password reveal + paste, overscan margins, sign-out entry, neutral form
|
||||
focus colors, always-focusable Sign in with inline validation. Deferred from review:
|
||||
|
||||
- "Continue watching" refinement: auto-remove finished entries, exact resume (start
|
||||
playback at `userHistory.currentTime`, show "Resuming at 1:23" toast).
|
||||
- Trending/featured row on Home; rows per subscribed channel (SmartTube-like).
|
||||
- Video progress bars in ALL lists (needs per-video history merge; currently only
|
||||
Continue-watching has them).
|
||||
- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed;
|
||||
auto-next after video end; subtitles (captions) support; phone-QR-assisted login.
|
||||
- Drawer restructure proposal: Home / Channels (subs first) / Search / History /
|
||||
Account — decide together with merged-feed item below.
|
||||
- Unified "accessible videos" feed: merge public-local + own private into one paged,
|
||||
deduplicated list (v0.1 keeps Home rows + My videos).
|
||||
- Global local-channels browse (search endpoint rejects empty queries) — consider
|
||||
`GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`.
|
||||
- Logout / switch account: sign-out entry exists; add switch-account (re-login screen
|
||||
without losing scroll state).
|
||||
- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning.
|
||||
- Thumbnails of private/internal videos may 404 — on some instances thumbnail
|
||||
paths are publicly readable while on others they need auth; add a fallback
|
||||
poster only if needed.
|
||||
- Security note (verified live against tube.jeena.net): some instances serve
|
||||
non-public media / thumbnails from object storage with only URL-based access
|
||||
control. The app must not rely on that laxness; always send auth headers
|
||||
/ file tokens for non-public media. (Detail intentionally omitted from the
|
||||
public repo — report instance-specific findings privately.)
|
||||
- Verify `searchTarget=local` channel search behaves well for instances without
|
||||
federation; consider `search/video-channels` endpoint as an alternative.
|
||||
- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.
|
||||
- Verify `searchTarget=local` channel search behaves well for instances without
|
||||
federation; consider `search/video-channels` endpoint as an alternative.
|
||||
- (Verified 2026-10-10 via the test account) history subscriptions + watching endpoints
|
||||
behave as documented; upload seeding uses **flat multipart form fields** on
|
||||
`/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for
|
||||
seeding test data, the app itself doesn't upload.
|
||||
- Error UX: friendlier messages (offline, instance down, wrong credentials vs. expired
|
||||
token → re-login screen), retry buttons.
|
||||
- i18n: move user-facing strings to `strings.xml`, consider German + English.
|
||||
- Video detail screen (description, tags, views, duration, channel page link).
|
||||
|
||||
## Longer term
|
||||
|
||||
- Multiple saved instances / switching between them.
|
||||
- Live streams playback (HLS live), premieres.
|
||||
- Subtitles/captions selection.
|
||||
- Downloads for offline watching (probably limited to public videos).
|
||||
- Comments viewing (read-only) — needs moderation-safe UI thinking.
|
||||
- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items.
|
||||
- Out of scope by user request (do not implement without asking): uploading, editing,
|
||||
creating channels/users. (Stale members of this list — subscribing, playlists
|
||||
viewing/creation and rating — shipped with the player action row in v0.1; playlist
|
||||
deletion was requested and added later, see below.)
|
||||
- Delete own playlist: long-press OK on a playlist row (key-repeat) → confirmation
|
||||
dialog → `DELETE /api/v1/video-playlists/{id}`; toggle stays on short press. Requested
|
||||
2026-10-13.
|
||||
88
README.md
|
|
@ -4,91 +4,71 @@
|
|||
<img src="docs/jtube-logo.svg" width="150" alt="The JTube TV logo, a little orange TV" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Watch your PeerTube instance from the couch, with the remote.</em>
|
||||
</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.
|
||||
|
||||
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
|
||||
It talks to any PeerTube instance over the
|
||||
[REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default
|
||||
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.
|
||||
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.
|
||||
|
||||
## What it looks like
|
||||
|
||||
The screenshots use a public demo instance with real, CC-licensed open-movie
|
||||
Screenshots come from a demo instance serving 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.
|
||||
|
||||
**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.
|
||||
The home screen. Continue watching picks up at the position the server saved,
|
||||
new videos first, page after page.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/home.png" width="800" alt="Home screen with Continue watching, New local videos and subscriptions rows" />
|
||||
<img src="docs/screenshots/home.png" width="800" alt="Home screen with Continue watching and New videos rows" />
|
||||
</p>
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
<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, subscriber and play counts on
|
||||
every card. Open a channel for its banner, its stats and all its videos — and a
|
||||
Subscribe button you can reach with the remote.
|
||||
Channels, one row per owner, with video 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, 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.
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/player.png" width="800" alt="Player with controls, seek bar and title" />
|
||||
</p>
|
||||
|
||||
## What it does
|
||||
## What it does in v0.1
|
||||
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
What it deliberately does not do: uploading, editing or deleting content.
|
||||
What it deliberately does not do (yet, or ever): uploading, editing,
|
||||
deleting, managing subscriptions. That's tracked in
|
||||
[BACKLOG.md](BACKLOG.md).
|
||||
|
||||
## Build
|
||||
|
||||
|
|
@ -122,6 +102,4 @@ A single-module app, deliberately small:
|
|||
|
||||
## 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.
|
||||
JTube TV is free software under [GPL-3.0-or-later](LICENSE).
|
||||
|
|
@ -10,6 +10,7 @@ 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 {
|
||||
|
|
@ -55,6 +56,16 @@ 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) {
|
||||
|
|
@ -63,10 +74,24 @@ class VideoListViewModel(
|
|||
repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChunkLoaded(chunk: List<VideoListItem>) {
|
||||
|
|
@ -78,6 +103,20 @@ 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>> {
|
||||
|
|
@ -111,9 +150,22 @@ 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,10 +4,6 @@ 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
|
||||
|
|
@ -97,16 +93,15 @@ 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 = if (mode == "playlists") "Playlists" else detail.name,
|
||||
text = when (mode) {
|
||||
"playlists" -> "Playlists"
|
||||
"info" -> "Info"
|
||||
else -> "Comments"
|
||||
},
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
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),
|
||||
modifier = Modifier.padding(20.dp),
|
||||
)
|
||||
when (mode) {
|
||||
"playlists" -> PlaylistsColumn(detail, onDismiss)
|
||||
|
|
@ -166,7 +161,19 @@ private fun InfoColumn(
|
|||
.distinct()
|
||||
.toList()
|
||||
|
||||
val annotated = buildAnnotatedString {
|
||||
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 {
|
||||
var rest = description
|
||||
while (true) {
|
||||
val match = UrlPattern.find(rest) ?: break
|
||||
|
|
@ -187,53 +194,26 @@ private fun InfoColumn(
|
|||
rest = rest.substringAfter(match.value)
|
||||
}
|
||||
append(rest)
|
||||
}
|
||||
|
||||
val descFocus = remember { FocusRequester() }
|
||||
val firstLinkFocus = remember { FocusRequester() }
|
||||
// Focus the description so the D-pad can scroll it right away (issue #23):
|
||||
// a description with no links used to have nothing focusable, so a long
|
||||
// text could never be scrolled down with the remote. Fall back to the
|
||||
// first link when there is no description text.
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
runCatching {
|
||||
if (description.isNotBlank()) descFocus.requestFocus()
|
||||
else firstLinkFocus.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (description.isNotBlank()) {
|
||||
DescriptionScroll(
|
||||
annotated = annotated,
|
||||
focusRequester = descFocus,
|
||||
onDismiss = onDismiss,
|
||||
onLeaveBottom = {
|
||||
if (links.isNotEmpty()) {
|
||||
runCatching { firstLinkFocus.requestFocus() }.isSuccess
|
||||
} else false
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
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),
|
||||
)
|
||||
LazyColumn(Modifier.heightIn(max = 220.dp)) {
|
||||
}
|
||||
links.forEachIndexed { index, url ->
|
||||
item(key = "l" + index + url) {
|
||||
SidebarRow(
|
||||
onLeft = onDismiss,
|
||||
onUp = if (index == 0 && description.isNotBlank()) {
|
||||
{ runCatching { descFocus.requestFocus() } }
|
||||
} else null,
|
||||
atTop = index == 0 && description.isBlank(),
|
||||
atBottom = index == links.size - 1,
|
||||
focusRequester = if (index == 0) firstLinkFocus else null,
|
||||
onClick = { open(url) },
|
||||
) {
|
||||
|
|
@ -248,65 +228,6 @@ private fun InfoColumn(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a
|
||||
* LazyColumn can't be scrolled with the remote — nothing in it takes focus — so
|
||||
* long descriptions were unreadable past the first screenful. This box takes
|
||||
* focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands
|
||||
* focus to the link list (if any).
|
||||
*/
|
||||
@Composable
|
||||
private fun DescriptionScroll(
|
||||
annotated: androidx.compose.ui.text.AnnotatedString,
|
||||
focusRequester: FocusRequester,
|
||||
onDismiss: () -> Unit,
|
||||
onLeaveBottom: () -> Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scroll = androidx.compose.foundation.rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
var viewportPx by remember { mutableStateOf(0) }
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.background(if (focused) Color(0x0DFFFFFF) else Color.Transparent)
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val step = (viewportPx * 0.85f).coerceAtLeast(240f)
|
||||
when (ke.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_DOWN ->
|
||||
if (scroll.value < scroll.maxValue) {
|
||||
scope.launch { scroll.animateScrollBy(step) }
|
||||
true
|
||||
} else onLeaveBottom()
|
||||
KeyEvent.KEYCODE_DPAD_UP ->
|
||||
if (scroll.value > 0) {
|
||||
scope.launch { scroll.animateScrollBy(-step) }
|
||||
true
|
||||
} else true // nothing above the description; stay put
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
onDismiss(); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.verticalScroll(scroll)
|
||||
.onSizeChanged { viewportPx = it.height },
|
||||
) {
|
||||
Text(
|
||||
text = annotated,
|
||||
color = TextMain,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Link click listener: the annotation callback shape. */
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ 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
|
||||
|
|
@ -64,9 +63,9 @@ 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
|
||||
|
||||
|
|
@ -94,6 +93,10 @@ 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())
|
||||
|
|
@ -123,9 +126,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
/** SystemClock at the last successful fast-path pass — re-entry freshness. */
|
||||
private var lastLoadedAt = 0L
|
||||
|
||||
// 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.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun load(force: Boolean = false) {
|
||||
// Re-entry (destination switch away + back) inside the freshness window:
|
||||
|
|
@ -210,6 +224,11 @@ 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)
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +302,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
.thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 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 ->
|
||||
|
|
@ -414,7 +439,6 @@ 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>,
|
||||
|
|
@ -457,8 +481,6 @@ private fun ChannelRow(
|
|||
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
// Land on the last-focused card when moving between rows (issue #29).
|
||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
|
|
@ -531,7 +553,7 @@ fun ChannelCard(
|
|||
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||
?: stats?.latestThumbUrl
|
||||
if (coverUrl != null) {
|
||||
ShimmerAsyncImage(
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
|
|
@ -545,7 +567,7 @@ fun ChannelCard(
|
|||
}
|
||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
ShimmerAsyncImage(
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
|
|
@ -554,12 +576,12 @@ fun ChannelCard(
|
|||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF333333)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ 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
|
||||
|
|
@ -41,6 +40,7 @@ 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,6 +57,8 @@ 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)
|
||||
|
|
@ -73,24 +75,36 @@ 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)
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchSubs() {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -101,6 +115,12 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +176,6 @@ 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()) {
|
||||
|
|
@ -164,7 +183,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video })
|
||||
}
|
||||
continueInFlight = false
|
||||
continueLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +190,6 @@ 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
|
||||
|
|
@ -182,7 +199,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel(
|
|||
net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page)
|
||||
}
|
||||
subsInFlight = false
|
||||
subsLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +210,12 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,8 +235,28 @@ 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>> =
|
||||
|
|
@ -272,10 +314,9 @@ fun HomeRowsScreen(
|
|||
}
|
||||
item {
|
||||
if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) {
|
||||
// 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.
|
||||
// Dummy cards with the faint shimmer while the row loads (#19);
|
||||
// "Watch something first" only when there is genuinely no history.
|
||||
ShimmerRow(size = 1)
|
||||
ShimmerRow()
|
||||
} else if (viewModel.continueItems.isEmpty()) {
|
||||
Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) {
|
||||
Text(
|
||||
|
|
@ -297,7 +338,6 @@ 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) {
|
||||
|
|
@ -317,7 +357,7 @@ fun HomeRowsScreen(
|
|||
item {
|
||||
if (recentItems.isEmpty()) {
|
||||
LaunchedEffect("kick-recent") { viewModel.recent.loadMore() }
|
||||
ShimmerRow(size = 1)
|
||||
ShimmerRow()
|
||||
} else {
|
||||
FocusScrollRow(
|
||||
videos = recentItems,
|
||||
|
|
@ -327,7 +367,6 @@ 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() }
|
||||
|
|
@ -346,7 +385,7 @@ fun HomeRowsScreen(
|
|||
}
|
||||
item {
|
||||
if (viewModel.subItems.isEmpty() && viewModel.subsLoading) {
|
||||
ShimmerRow(size = 1)
|
||||
ShimmerRow()
|
||||
} else {
|
||||
FocusScrollRow(
|
||||
videos = viewModel.subItems,
|
||||
|
|
@ -356,7 +395,6 @@ fun HomeRowsScreen(
|
|||
clickedIndex = subRestore,
|
||||
restoreToken = restoreToken,
|
||||
refocusPulse = drawerPulse,
|
||||
trailingLoading = viewModel.subsLoadingMore,
|
||||
) { index, video ->
|
||||
if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||
LaunchedEffect(index) { viewModel.subsMore() }
|
||||
|
|
@ -386,7 +424,6 @@ 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>,
|
||||
|
|
@ -405,9 +442,6 @@ 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()
|
||||
|
|
@ -455,10 +489,6 @@ fun FocusScrollRow(
|
|||
}
|
||||
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.
|
||||
modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
|
|
@ -482,14 +512,5 @@ fun FocusScrollRow(
|
|||
)
|
||||
afterItem(index, video)
|
||||
}
|
||||
if (trailingLoading) {
|
||||
item(key = "trailing-loading") {
|
||||
ShimmerCard(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.width(rowCardWidth()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,15 +114,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// BACK order (TV standard): player (own handler) → drawer close → channel
|
||||
// drilldown → non-Home destination → Home; from Home: double-press exits.
|
||||
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
||||
if (destination == Destination.Home && channelDetails == 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 {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
|
|
@ -130,16 +134,8 @@ 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
|
||||
|
|
@ -155,9 +151,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
when {
|
||||
drawerOpen -> { dismissDrawer(); true }
|
||||
channelDetails != null -> { channelDetails = null; true }
|
||||
// Mirror the D-pad BACK model (issue #26): Esc opens the
|
||||
// sidebar from a top-level list view too.
|
||||
else -> { drawerOpen = true; true }
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
},
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ fun VideoCard(
|
|||
.data(video.thumbnailUrl ?: AppGraph.repository.absoluteUrl(path))
|
||||
.size(480, 270) // ~2x the drawn thumb; avoids decoding 1080p+ jpegs
|
||||
.build()
|
||||
ShimmerAsyncImage(
|
||||
AsyncImage(
|
||||
model = req,
|
||||
contentDescription = video.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -320,48 +320,6 @@ 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,11 +1,8 @@
|
|||
package net.jeena.jtubetv.ui.screens.list
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.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
|
||||
|
|
@ -162,29 +159,22 @@ fun VideoListScreen(
|
|||
return
|
||||
}
|
||||
|
||||
androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) {
|
||||
// Channel page: the header is PINNED above the grid (issue #27) rather than
|
||||
// scrolling away as the first grid item — auto-focusing the first video card
|
||||
// no longer hides the banner, and the Subscribe button on it stays reachable.
|
||||
if (source is ListSource.Channel) {
|
||||
ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
handle = source.handle,
|
||||
onLeftEdge = onOpenDrawer,
|
||||
)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||
) {
|
||||
// Search field still scrolls with its results (its own header item);
|
||||
// the channel banner is pinned above the grid instead (issue #27).
|
||||
if (source is ListSource.Search) {
|
||||
// Page headers (search field / channel banner) live INSIDE the grid so
|
||||
// they scroll with the content and never stack under the cards.
|
||||
item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
|
||||
SearchHeader(viewModel, onOpenDrawer)
|
||||
when (source) {
|
||||
is ListSource.Search -> SearchHeader(viewModel, onOpenDrawer)
|
||||
is ListSource.Channel -> ChannelHeader(
|
||||
channelHeader = viewModel.channelHeader,
|
||||
videoTotal = viewModel.videoTotal,
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) {
|
||||
|
|
@ -249,18 +239,21 @@ fun VideoListScreen(
|
|||
}
|
||||
}
|
||||
if (viewModel.loadingMore) {
|
||||
// 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") {
|
||||
// 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) {
|
||||
ShimmerCard(
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.width(rowCardWidth() - 12.dp),
|
||||
.width(rowCardWidth()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -403,113 +396,62 @@ private fun ScopeChip(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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's pinned above the grid so
|
||||
* it never scrolls away, and the video cards start directly beneath it.
|
||||
*/
|
||||
/** 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. */
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?,
|
||||
videoTotal: Long = 0,
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit = {},
|
||||
) {
|
||||
if (channelHeader == null) return
|
||||
val metaColor = Color(0xFFCFCFCF)
|
||||
val metaColor = Color(0xFF9A9A9A)
|
||||
androidx.compose.foundation.layout.Column {
|
||||
val banner = channelHeader.banners.maxByOrNull { it.width }
|
||||
val description = channelHeader.description?.takeIf { it.isNotBlank() }
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 4.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Banner area: a fixed height with the identity + stats anchored to its
|
||||
// bottom. A long description flows into the black strip BELOW it rather than
|
||||
// being pushed up over the banner or clipped (user request).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(178.dp)
|
||||
.background(Color(0xFF1C1C1C)),
|
||||
) {
|
||||
if (banner != null) {
|
||||
ShimmerAsyncImage(
|
||||
AsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
|
||||
)
|
||||
}
|
||||
// Darkening overlay, fading to solid black at the bottom so the banner
|
||||
// blends seamlessly into the description strip beneath it.
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color(0x33000000),
|
||||
0.5f to Color(0x99000000),
|
||||
1f to Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Identity + stats, bottom-left. The avatar sits beside the title (as it
|
||||
// did before the redesign — user request).
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val avatar = channelHeader.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
ShimmerAsyncImage(
|
||||
AsyncImage(
|
||||
model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
base = Color(0xFF333333),
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.padding(start = 16.dp, top = 10.dp)
|
||||
.size(52.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 14.dp)) {
|
||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
text = channelHeader.displayName,
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
// "by <owner> on <platform>" instead of the raw handle (user
|
||||
// request): the handle read as noise on a personal instance.
|
||||
val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name }
|
||||
val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host
|
||||
val byline = when {
|
||||
owner != null && platform != null -> "by $owner on $platform"
|
||||
owner != null -> "by $owner"
|
||||
platform != null -> "on $platform"
|
||||
else -> ""
|
||||
}
|
||||
if (byline.isNotBlank()) {
|
||||
Text(
|
||||
text = byline,
|
||||
color = Color(0xFFBDBDBD),
|
||||
text = channelHeader.name + "@" + channelHeader.host,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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(top = 6.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp),
|
||||
) {
|
||||
headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor)
|
||||
val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount
|
||||
|
|
@ -520,102 +462,32 @@ private fun ChannelHeader(
|
|||
headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
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),
|
||||
)
|
||||
}
|
||||
// Description in the black beneath the banner: a long one grows the header
|
||||
// downward, fading out of the banner that already darkens into black.
|
||||
if (description != null) {
|
||||
channelHeader.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
color = Color(0xFFCCCCCC),
|
||||
color = Color(0xFFB8B8B8),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 3,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe/unsubscribe toggle for the channel header (issue #27). Mirrors the
|
||||
* player's action: the server's follow-exists check can lag for federated
|
||||
* channels, so a successful toggle trusts the user's action and re-syncs on the
|
||||
* next screen entry. One request at a time ([busy]).
|
||||
*/
|
||||
@Composable
|
||||
private fun SubscribeButton(
|
||||
handle: String,
|
||||
onLeftEdge: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = AppGraph.repository
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var subscribed by remember(handle) { mutableStateOf<Boolean?>(null) }
|
||||
var busy by remember(handle) { mutableStateOf(false) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(handle) {
|
||||
repository.subscriptionExists(handle).onSuccess { subscribed = it }
|
||||
}
|
||||
val isSub = subscribed == true
|
||||
val bg = when {
|
||||
focused -> Color.White
|
||||
isSub -> Color(0x33CE2B2B)
|
||||
else -> Color(0xFFCE2B2B)
|
||||
}
|
||||
val fg = if (focused) Color(0xFF101010) else Color.White
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(20.dp))
|
||||
.background(bg)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT
|
||||
) {
|
||||
onLeftEdge(); true
|
||||
} else false
|
||||
}
|
||||
.dpadCenterActivatable {
|
||||
if (busy) return@dpadCenterActivatable
|
||||
busy = true
|
||||
scope.launch {
|
||||
val wasSub = subscribed == true
|
||||
val ok = if (wasSub) repository.unsubscribe(handle).isSuccess
|
||||
else repository.subscribe(handle).isSuccess
|
||||
if (ok) subscribed = !wasSub
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = if (isSub) Icons.Filled.Check else Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
tint = fg,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (subscribed == null) "…" else if (isSub) "Subscribed" else "Subscribe",
|
||||
color = fg,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun headerStat(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 486 B |
|
Before Width: | Height: | Size: 572 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 635 KiB After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 908 KiB |