diff --git a/AGENTS.md b/AGENTS.md index 58ee33e..b08eb14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). \ No newline at end of file diff --git a/BACKLOG.md b/BACKLOG.md deleted file mode 100644 index 1ee7edd..0000000 --- a/BACKLOG.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 12c9cbf..07c09d8 100644 --- a/README.md +++ b/README.md @@ -4,71 +4,97 @@ The JTube TV logo, a little orange TV

-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. +

+ Watch your PeerTube instance from the couch, with the remote. +

-It talks to any PeerTube instance over the +JTube TV is an Android TV app for + PeerTube. +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.

- Home screen with Continue watching and New videos rows + Home screen with Continue watching, New local videos and subscriptions rows

-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.

Menu drawer with the destinations

+**My videos.** Everything you own, every privacy level, with a badge on +anything that isn't public. +

My videos grid with Private and Internal badges

-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.

Channels screen with channel cards

-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. + +

+ A channel page: banner, stats, Subscribe button and the channel's videos +

+ +**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.

Player with controls, seek bar and title

-## 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). \ No newline at end of file +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. diff --git a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt index fe13971..a9d3f43 100644 --- a/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt +++ b/app/src/main/java/net/jeena/jtubetv/JTubeApp.kt @@ -1,6 +1,7 @@ package net.jeena.jtubetv import android.app.Application +import kotlinx.coroutines.Dispatchers import coil.ImageLoader import coil.ImageLoaderFactory import net.jeena.jtubetv.data.PeerTubeRepository @@ -33,9 +34,20 @@ class JTubeApp : Application(), coil.ImageLoaderFactory { * TVs are slow compositors: decode thumbnails as RGB_565 at capped size so * scrolling rows keep a smooth frame rate (user: "the animation has very low fps"). */ - override fun newImageLoader(): ImageLoader = - ImageLoader.Builder(this) + override fun newImageLoader(): ImageLoader { + // A sched trace on the TV (release build) showed the main thread being + // descheduled hundreds of times during navigation while the render + // pipeline (Mali/RenderThread/SF/Dolby) saturated all 4 cores together + // with Coil's image work — a burst of parallel 30 ms bitmap decodes + // starved the main thread for over a second (the felt "2fps"). Capping + // Coil's parallel work: images still fill in steadily while scrolling, + // but the decode/fetch pool can no longer eat every core at once. + return ImageLoader.Builder(this) .bitmapConfig(android.graphics.Bitmap.Config.RGB_565) .crossfade(false) + .dispatcher(Dispatchers.IO.limitedParallelism(2)) + .decoderDispatcher(Dispatchers.Default.limitedParallelism(2)) + .transformationDispatcher(Dispatchers.Default.limitedParallelism(1)) .build() + } } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt index 3a10e30..2c811d3 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/list/VideoListViewModel.kt @@ -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) { @@ -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().take(50).map { it.video }, - ) - } - } - } } override suspend fun fetch(start: Long, count: Int): Result> { @@ -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) - } - } } } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt index ced63ff..7666884 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/player/PlayerSidebar.kt @@ -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,72 +168,127 @@ private fun InfoColumn( .distinct() .toList() - val firstLinkFocus = remember { FocusRequester() } - LaunchedEffect(links) { - if (links.isNotEmpty()) { - kotlinx.coroutines.delay(150) - runCatching { firstLinkFocus.requestFocus() } + val annotated = buildAnnotatedString { + var rest = description + while (true) { + val match = UrlPattern.find(rest) ?: break + append(rest.substringBefore(match.value)) + val url = match.value.trimEnd('.', ',', ')') + withLink( + LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle( + color = Color(0xFF66B2FF), + textDecoration = TextDecoration.Underline, + ), + ), + { _ -> open(url) }, + ), + ) { append(url) } + rest = rest.substringAfter(match.value) } + append(rest) } - LazyColumn(Modifier.fillMaxSize()) { - if (description.isNotBlank()) { - item(key = "desc") { - Text( - text = buildAnnotatedString { - var rest = description - while (true) { - val match = UrlPattern.find(rest) ?: break - append(rest.substringBefore(match.value)) - val url = match.value.trimEnd('.', ',', ')') - withLink( - LinkAnnotation.Url( - url, - TextLinkStyles( - SpanStyle( - color = Color(0xFF66B2FF), - textDecoration = TextDecoration.Underline, - ), - ), - { _ -> open(url) }, - ), - ) { append(url) } - rest = rest.substringAfter(match.value) - } - append(rest) - }, - color = TextMain, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), - ) - } + 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), + ) } - 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, - ) + } +} + +/** + * 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), + ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index 203fbf7..1de2c73 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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 ("'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 ("'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(null) private set var userChannels by mutableStateOf>(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 { 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 { 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,21 +338,18 @@ 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 - .width(180.dp) - .height(22.dp) - .clip(CircleShape) - .background(Color(0xFF232323)), - ) - } - ShimmerRow() + Box(Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)) { + Box( + Modifier + .width(180.dp) + .height(22.dp) + .clip(CircleShape) + .background(Color(0xFF232323)), + ) } + 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, @@ -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), ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt index bca62ff..53d8625 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeRows.kt @@ -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>(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() { - // 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) { 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> = @@ -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, @@ -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)) +} diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index e14554d..1e28d40 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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(null) } // handle var channelName by rememberSaveable { mutableStateOf(null) } // display name + // Last channel opened from the Channels screen — refocused on return (#34). + var lastOpenedChannel by rememberSaveable { mutableStateOf(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(null) } // shortUUID // Non-null while the playing video lives on ANOTHER instance (vidiverse results). var playingVideoHost by rememberSaveable { mutableStateOf(null) } + // Thumbnail of the opened card — shown as the player's poster while it loads. + var playingVideoThumb by rememberSaveable { mutableStateOf(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 }, ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt index 3ba847b..310584c 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/PlayerScreen.kt @@ -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), ) } } diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt index d80fa37..e910f5b 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoCard.kt @@ -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) - // 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( + // 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() + } + 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( diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt index 571e02f..8e06582 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/VideoListScreen.kt @@ -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() + // 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() } + // 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,16 +193,21 @@ 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. - 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, - ) - else -> {} + // 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) { @@ -199,21 +232,14 @@ 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) { - ShimmerCard( - modifier = Modifier - .padding(6.dp) - .width(rowCardWidth()), - ) - } - } - } - } + // A single loading card, not a full fake grid (issue #31). + ShimmerCard( + modifier = Modifier + .padding(6.dp) + .width(rowCardWidth() - 12.dp), + ) } } itemsIndexed(items, key = { _, item -> itemStableKey(item) }) { index, item -> @@ -239,18 +265,14 @@ 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) { - ShimmerCard( - modifier = Modifier - .padding(6.dp) - .width(rowCardWidth()), - ) - } - } + // 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() - 12.dp), + ) } } } @@ -396,96 +418,259 @@ 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 = {}, ) { - if (channelHeader == null) return - val metaColor = Color(0xFF9A9A9A) - androidx.compose.foundation.layout.Column { - val banner = channelHeader.banners.maxByOrNull { it.width } + 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 (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(), ) } - Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = channelHeader.avatars.maxByOrNull { it.width } - if (avatar != null) { - AsyncImage( - model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .padding(start = 16.dp, top = 10.dp) - .size(52.dp) - .clip(androidx.compose.foundation.shape.CircleShape), - ) - } - Column(modifier = Modifier.padding(start = 16.dp)) { - Text( - text = channelHeader.displayName, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - ) - Text( - text = channelHeader.name + "@" + channelHeader.host, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - // Metadata strip between the identity and the videos (issue #14): the - // counts SmartTube shows, plus creation date and the owning user. - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(start = 16.dp, top = 8.dp), + // 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), ) { - headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) - val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount - if (videoCount > 0) { - headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor) + Row(verticalAlignment = Alignment.CenterVertically) { + val avatar = channelHeader.avatars.maxByOrNull { it.width } + if (avatar != null) { + ShimmerAsyncImage( + model = AppGraph.repository.absoluteUrl(avatar.fileUrl ?: avatar.path), + contentDescription = null, + contentScale = ContentScale.Crop, + base = Color(0xFF333333), + modifier = Modifier + .size(56.dp) + .clip(androidx.compose.foundation.shape.CircleShape), + ) + } 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 = 14.dp)) { + Text( + text = channelHeader.displayName, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + // "by on " instead of the raw handle (user + // request): the handle read as noise on a personal instance. + val owner = channelHeader.ownerAccount?.let { it.displayName ?: it.name } + val platform = AppGraph.tokenStore.instanceName ?: channelHeader.host + val byline = when { + owner != null && platform != null -> "by $owner on $platform" + owner != null -> "by $owner" + platform != null -> "on $platform" + else -> "" + } + if (byline.isNotBlank()) { + Text( + text = byline, + color = Color(0xFFBDBDBD), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } } - if (channelHeader.createdAt != null) { - headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) + val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount + if (videoCount > 0) { + headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor) + } + if (channelHeader.createdAt != null) { + headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) + } } } - val owner = channelHeader.ownerAccount - 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 { + 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(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 diff --git a/docs/peertube-logo.svg b/docs/peertube-logo.svg new file mode 100644 index 0000000..4f07ef9 --- /dev/null +++ b/docs/peertube-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/screenshots/channel.png b/docs/screenshots/channel.png new file mode 100644 index 0000000..d860148 Binary files /dev/null and b/docs/screenshots/channel.png differ diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png index 11d2afe..230212d 100644 Binary files a/docs/screenshots/channels.png and b/docs/screenshots/channels.png differ diff --git a/docs/screenshots/drawer.png b/docs/screenshots/drawer.png index 7b27e6c..986b103 100644 Binary files a/docs/screenshots/drawer.png and b/docs/screenshots/drawer.png differ diff --git a/docs/screenshots/home.png b/docs/screenshots/home.png index 5a2d6dd..8ddd367 100644 Binary files a/docs/screenshots/home.png and b/docs/screenshots/home.png differ diff --git a/docs/screenshots/myvideos.png b/docs/screenshots/myvideos.png index c0b1af9..251714c 100644 Binary files a/docs/screenshots/myvideos.png and b/docs/screenshots/myvideos.png differ diff --git a/docs/screenshots/player.png b/docs/screenshots/player.png index 303e0c0..5069216 100644 Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ diff --git a/scripts/start-emulator.sh b/scripts/start-emulator.sh index fdb9af7..a4a81d7 100755 --- a/scripts/start-emulator.sh +++ b/scripts/start-emulator.sh @@ -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.