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..f2add78 100644 --- a/README.md +++ b/README.md @@ -4,71 +4,91 @@ 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. Open a channel for its banner, its stats and all its videos — and a +Subscribe button you can reach with the remote.

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. +**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 +122,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/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index e1d9e0f..343deec 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -1,7 +1,10 @@ - + - \ 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..6ca82bb 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 @@ -93,15 +97,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,68 +166,84 @@ private fun InfoColumn( .distinct() .toList() + val annotated = buildAnnotatedString { + var rest = description + while (true) { + val match = UrlPattern.find(rest) ?: break + append(rest.substringBefore(match.value)) + val url = match.value.trimEnd('.', ',', ')') + withLink( + LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle( + color = Color(0xFF66B2FF), + textDecoration = TextDecoration.Underline, + ), + ), + { _ -> open(url) }, + ), + ) { append(url) } + rest = rest.substringAfter(match.value) + } + append(rest) + } + + val descFocus = remember { FocusRequester() } val firstLinkFocus = remember { FocusRequester() } - LaunchedEffect(links) { - if (links.isNotEmpty()) { - kotlinx.coroutines.delay(150) - runCatching { firstLinkFocus.requestFocus() } + // 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() } } - LazyColumn(Modifier.fillMaxSize()) { + Column(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), - ) - } + DescriptionScroll( + annotated = annotated, + focusRequester = descFocus, + onDismiss = onDismiss, + onLeaveBottom = { + if (links.isNotEmpty()) { + runCatching { firstLinkFocus.requestFocus() }.isSuccess + } else false + }, + 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, - ) + 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) }, + ) { + Text( + text = linkLabel(url), + color = TextMain, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } } } } @@ -230,6 +251,64 @@ private fun InfoColumn( } } +/** + * Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a + * LazyColumn can't be scrolled with the remote — nothing in it takes focus — so + * long descriptions were unreadable past the first screenful. This box takes + * focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands + * focus to the link list (if any). + */ +@Composable +private fun DescriptionScroll( + annotated: androidx.compose.ui.text.AnnotatedString, + focusRequester: FocusRequester, + onDismiss: () -> Unit, + onLeaveBottom: () -> Boolean, + modifier: Modifier = Modifier, +) { + val scroll = androidx.compose.foundation.rememberScrollState() + val scope = rememberCoroutineScope() + var focused by remember { mutableStateOf(false) } + var viewportPx by remember { mutableStateOf(0) } + Box( + modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .focusRequester(focusRequester) + .focusable() + .background(if (focused) Color(0x0DFFFFFF) else Color.Transparent) + .onPreviewKeyEvent { ke -> + if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val step = (viewportPx * 0.85f).coerceAtLeast(240f) + when (ke.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> + if (scroll.value < scroll.maxValue) { + scope.launch { scroll.animateScrollBy(step) } + true + } else onLeaveBottom() + KeyEvent.KEYCODE_DPAD_UP -> + if (scroll.value > 0) { + scope.launch { scroll.animateScrollBy(-step) } + true + } else true // nothing above the description; stay put + KeyEvent.KEYCODE_DPAD_LEFT -> { + onDismiss(); true + } + else -> false + } + } + .verticalScroll(scroll) + .onSizeChanged { viewportPx = it.height }, + ) { + Text( + text = annotated, + color = TextMain, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } +} + /** Link click listener: the annotation callback shape. */ private fun openLinkFor(ann: LinkAnnotation, block: (LinkAnnotation) -> Unit) = block(ann) 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..131a64d 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 @@ -37,6 +37,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,9 +64,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 @@ -93,10 +94,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,20 +123,9 @@ 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. fun load(force: Boolean = false) { // Re-entry (destination switch away + back) inside the freshness window: @@ -224,11 +210,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) } @@ -302,12 +283,6 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { 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 -> @@ -439,6 +414,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, @@ -481,6 +457,8 @@ 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), ) { @@ -553,7 +531,7 @@ fun ChannelCard( val coverUrl = banner?.let { absoluteBanner(it) } ?: stats?.latestThumbUrl if (coverUrl != null) { - AsyncImage( + ShimmerAsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -567,7 +545,7 @@ fun ChannelCard( } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { - AsyncImage( + ShimmerAsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -576,12 +554,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) - .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..7ebda20 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 @@ -14,6 +14,7 @@ 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 +41,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 +57,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 +73,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 +101,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 +156,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 +164,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 +172,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 +182,7 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page) } subsInFlight = false + subsLoadingMore = false } } @@ -210,12 +194,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 +213,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 +272,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 +297,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 +317,7 @@ fun HomeRowsScreen( item { if (recentItems.isEmpty()) { LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } - ShimmerRow() + ShimmerRow(size = 1) } else { FocusScrollRow( videos = recentItems, @@ -367,6 +327,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 +346,7 @@ fun HomeRowsScreen( } item { if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { - ShimmerRow() + ShimmerRow(size = 1) } else { FocusScrollRow( videos = viewModel.subItems, @@ -395,6 +356,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 +386,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 +405,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() @@ -489,6 +455,10 @@ 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), ) { @@ -512,5 +482,14 @@ fun FocusScrollRow( ) afterItem(index, video) } + if (trailingLoading) { + item(key = "trailing-loading") { + ShimmerCard( + modifier = Modifier + .padding(horizontal = 4.dp) + .width(rowCardWidth()), + ) + } + } } } 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..019e2ff 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 @@ -114,19 +114,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 +130,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 +155,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 }, 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..0a1c3a2 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 @@ -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() - AsyncImage( + ShimmerAsyncImage( model = req, contentDescription = video.name, contentScale = ContentScale.Crop, @@ -320,6 +320,48 @@ fun ShimmerCard(modifier: Modifier = Modifier) { } } +/** + * Coil image that shows the same faint shimmer as [ShimmerCard] while it loads + * (issue #28) — thumbnails, channel banners and avatars no longer pop in from a + * flat grey rectangle. The sweep runs only while loading: once the image (or an + * error) arrives, the overlay leaves composition and its animation disposes. + */ +@Composable +fun ShimmerAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + base: Color = Color(0xFF222222), +) { + var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) } + Box(modifier.background(base)) { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier.matchParentSize(), + onState = { state -> + loaded = state is coil.compose.AsyncImagePainter.State.Success || + state is coil.compose.AsyncImagePainter.State.Error + }, + ) + if (!loaded) ShimmerFill(Modifier.matchParentSize(), base) + } +} + +/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */ +@Composable +private fun ShimmerFill(modifier: Modifier, base: Color) { + val sweep = rememberInfiniteTransition() + val x by sweep.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)), + ) + Box(modifier.background(base).shimmer(x)) +} + /** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ 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..cedb561 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,11 @@ 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 @@ -159,22 +162,29 @@ 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.fillMaxSize().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp), ) { - // Page headers (search field / channel banner) live INSIDE the grid so - // 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 -> {} + // 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) { + item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + SearchHeader(viewModel, onOpenDrawer) } } if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { @@ -239,21 +249,18 @@ fun VideoListScreen( } } if (viewModel.loadingMore) { - // Growing the list: placeholder row (issue #19) instead of the - // permanent spinner chip that used to sit under every list. - item(key = "load-more-placeholders") { - androidx.compose.foundation.layout.Row { - repeat(cardsPerRow) { - 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), + ) } } } + } } @Composable @@ -396,95 +403,216 @@ private fun ScopeChip( } } -/** Channel page header: banner strip, then avatar + display name + handle, - * a metadata strip (subscribers/videos/created + owner, issue #14) and the - * channel 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's pinned above the grid so + * it never scrolls away, and the video cards start directly beneath it. + */ @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() } + 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) { - 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). + 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), + ) + } + 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), - ) - } - channelHeader.description?.takeIf { it.isNotBlank() }?.let { description -> - Text( - text = description, - 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 = 2.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), + ) + } + // Description in the black beneath the banner: a long one grows the header + // downward, fading out of the banner that already darkens into black. + if (description != null) { + Text( + text = description, + color = Color(0xFFCCCCCC), + 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), + ) + } + } +} + +/** + * 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, + ) } } 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/channels.png b/docs/screenshots/channels.png index 11d2afe..f88dc4e 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..5559078 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..1e150b8 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..5321a83 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..f722916 100644 Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ diff --git a/tools/mock/README.md b/tools/mock/README.md new file mode 100644 index 0000000..5104500 --- /dev/null +++ b/tools/mock/README.md @@ -0,0 +1,29 @@ +# Screenshot mock backend + +Throwaway tooling to regenerate the README screenshots without exposing the +private family content on the real instance. Lives on the `tools/screenshot-mock` +branch only — `main` never carries the mock or the cleartext flag. + +`mock_peertube.py` is a thin HTTP proxy to the public PeerTube demo instance +(`peertube2.cpy.re`, real CC/Blender content) with a handful of overrides so the +authenticated screens are populated without an account (login always succeeds, a +"Demo User" with an avatar, Continue watching / My videos / Subscriptions each +show a different slice so thumbnails don't repeat, My videos gets privacy badges, +and a channel page shows the full video grid). Media and images still load +straight from peertube2 over HTTPS; only the JSON and thumbnails pass through the +proxy. The app itself runs unmodified — the only app-side change is the +debug-only `android:usesCleartextTraffic="true"` in `app/src/debug/` that lets a +debug build reach the proxy over HTTP. + +## Regenerate the screenshots + +1. `git checkout tools/screenshot-mock` +2. Start the proxy on the host: `python3 tools/mock/mock_peertube.py` (port 8080; + needs only Python 3 stdlib). +3. Build + install the debug build on the emulator: + `./gradlew :app:assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk` +4. On the setup screen enter `http://10.0.2.2:8080` (the emulator's alias for the + host) and sign in with any username/password. +5. Drive the UI with `adb shell input …` and `adb shell screencap` to capture + `home`, `drawer`, `myvideos`, `channels`, `player`, and a channel page. +6. Commit the PNGs on `main` (only the images), not here. diff --git a/tools/mock/mock_peertube.py b/tools/mock/mock_peertube.py new file mode 100644 index 0000000..6f2235e --- /dev/null +++ b/tools/mock/mock_peertube.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Mock PeerTube backend for README screenshots. + +It is a thin HTTP proxy to the public PeerTube demo instance +(peertube2.cpy.re, real CC/Blender content) with a few overrides so the +authenticated screens are populated without a real account: + + - login always succeeds (oauth-clients/local + users/token are canned), + - /users/me is a "Demo User" with an avatar, + - Continue watching / My videos / Subscriptions each proxy a DIFFERENT + window of the public video list, so the rows don't repeat thumbnails, + - My videos gets Private/Internal/Public badges, + - a channel page shows the full video grid (channel .../videos is widened), + - a video opened from Continue watching gets a fake resume position. + +Everything else (video lists, video detail with its absolute HLS URLs, channel +banners/avatars, config, thumbnails) is proxied verbatim, so media and images +load straight from peertube2 over HTTPS; only the JSON + thumbnails pass through +here. The app runs UNMODIFIED against it (a debug-only cleartext flag in +app/src/debug lets it talk HTTP to this proxy). + +Usage: + python3 tools/mock/mock_peertube.py # listens on 0.0.0.0:8080 +Then in the app's setup screen use http://10.0.2.2:8080 (emulator) and sign +in with any username/password. +""" +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +UPSTREAM = "https://peertube2.cpy.re" +UP_HOST = "peertube2.cpy.re" +AVATAR = UPSTREAM + "/lazy-static/avatars/73e78544-cbae-439e-8c84-98b0756fbde7.jpg" +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 +# How the emulator addresses this proxy. The app decides a video/channel is +# "local" by matching its host against the instance host, so we relabel the +# upstream host to ours in the JSON (only the `host` fields, never URLs, so +# thumbnails/media keep pointing at peertube2). +MOCK_HOST = f"10.0.2.2:{PORT}" + + +def relabel_hosts(obj): + if isinstance(obj, dict): + return {k: (MOCK_HOST if k == "host" and v == UP_HOST else relabel_hosts(v)) for k, v in obj.items()} + if isinstance(obj, list): + return [relabel_hosts(x) for x in obj] + return obj + + +def fetch(path_with_query): + req = urllib.request.Request(UPSTREAM + path_with_query, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, r.headers.get_content_type(), r.read() + + +def fetch_videos(start, count, sort="-publishedAt"): + _, _, body = fetch(f"/api/v1/videos?isLocal=true&start={start}&count={count}&sort={sort}") + return json.loads(body) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # quiet + pass + + def _json(self, obj, code=200): + data = json.dumps(relabel_hosts(obj)).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _raw(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _q(self, query, name, default): + return urllib.parse.parse_qs(query).get(name, [default])[0] + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + self.rfile.read(int(self.headers.get("Content-Length", 0) or 0)) + if path == "/api/v1/users/token": + return self._json({ + "token_type": "Bearer", "access_token": "demo", + "refresh_token": "demo", "expires_in": 86400, + }) + if re.match(r"^/api/v1/videos/[^/]+/watching$", path): + self.send_response(204) + self.end_headers() + return + self._json({}, 204) + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + path, query = parsed.path, parsed.query + start = lambda off: int(self._q(query, "start", "0")) + off # noqa: E731 + count = int(self._q(query, "count", "25")) + + if path == "/api/v1/oauth-clients/local": + return self._json({"client_id": "demo", "client_secret": "demo"}) + if path == "/api/v1/users/me": + return self._json({ + "username": "demo", + "account": {"displayName": "Demo User", + "avatars": [{"width": 120, "fileUrl": AVATAR}]}, + }) + if path == "/api/v1/config": + return self._json({ + "instance": {"name": "PeerTube Demo", + "shortDescription": "Public demo content"}, + "search": {"searchIndex": {"enabled": False}}, + }) + if path == "/api/v1/users/me/videos": + data = fetch_videos(start(4), count) + for i, v in enumerate(data.get("data", [])): + v["privacy"] = {0: {"id": 3, "label": "Private"}, + 1: {"id": 4, "label": "Internal"}}.get(i % 3, {"id": 1, "label": "Public"}) + return self._json(data) + if path == "/api/v1/users/me/history/videos": + return self._json(fetch_videos(start(8), count)) + if path == "/api/v1/users/me/subscriptions/videos": + return self._json(fetch_videos(start(16), count)) + if path.startswith("/api/v1/users/me/subscriptions/exist"): + return self._json({}) + if re.match(r"^/api/v1/video-channels/[^/]+/videos$", path): + return self._json(fetch_videos(start(0), count)) # full grid on the channel page + m = re.match(r"^/api/v1/videos/([^/]+)$", path) + if m and self._q(query, "userHistory", "") == "true": + _, _, body = fetch(path) + detail = json.loads(body) + detail["userHistory"] = {"currentTime": int(detail.get("duration", 0) * 0.35)} + return self._json(detail) + + # Everything else (video lists, detail, channels, thumbnails) is proxied. + # A handle the app built with our host (name@10.0.2.2:PORT) is mapped back + # to the upstream host before we ask peertube2. + upstream = self.path.replace(MOCK_HOST, UP_HOST).replace( + MOCK_HOST.replace(":", "%3A"), UP_HOST) + try: + code, ctype, body = fetch(upstream) + if "json" in ctype: + self._json(json.loads(body), code) # relabel hosts in proxied JSON too + else: + self._raw(code, ctype, body) + except urllib.error.HTTPError as e: + self._raw(e.code, "application/json", e.read()) + except Exception as e: # noqa: BLE001 + self._raw(502, "text/plain", str(e).encode()) + + +if __name__ == "__main__": + print(f"Mock PeerTube proxy on http://0.0.0.0:{PORT} -> {UPSTREAM}") + print(f"Emulator: set instance URL to http://10.0.2.2:{PORT} and sign in with anything.") + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()