diff --git a/AGENTS.md b/AGENTS.md index b08eb14..58ee33e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,11 +113,10 @@ the workflow is strict: ## Scope discipline -Shipped features live in `README.md`; anything else is tracked as Forgejo issues at -git.jeena.net/jeena/jtube-tv. Do not sneak in uploading, editing, deletion or content -creation — the user explicitly excluded them. (Subscribing/unsubscribing to channels is -in scope and already implemented — on the player and the channel page.) If the user asks -for a scope change, confirm it with them before implementing. +v0.1 features live in `README.md`; everything else in `BACKLOG.md`. Do not sneak in +uploading, editing, subscriptions management, deletion or content creation — the user +explicitly excluded them. If the user asks for a scope change, update `BACKLOG.md` +first, then implement. When unsure how a PeerTube endpoint behaves, verify against the live instance instead of guessing (401 ⇒ exists & needs auth, 404 ⇒ route missing, 405 ⇒ wrong method on real route). \ No newline at end of file diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..1ee7edd --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,85 @@ +# Backlog + +Status vocabulary: `todo` · `doing` · `done`. + +## v0.1 — MVP (implemented, pending device sign-off) + +| # | Item | Status | Notes | +|---|------|--------|-------| +| 0 | Ask for credentials + instance URL, store safely | done | Login screen → `users/token`; tokens + OAuth client creds in EncryptedSharedPreferences; refresh single-flight; show/paste password helpers; debug-only seed intent for automation | +| 1 | Local videos + user's videos incl. private/internal | done | Home rows (SmartTube-style) + "My videos" grid with privacy badge; internal (instance-member) videos appear in listings too | +| 2 | Local channels | done | MVP = user's own channels (`accounts/{me}/video-channels`); search-based local listing needs non-empty query — revisit in v0.2 | +| 3 | Search local videos | done | Own screen in sidebar | +| 4 | Subscription videos | done | Verified incl. remote (federated) channels | +| 5 | Watch history (server) | done | read-only grid | +| 6 | Playback + report to server history | done | ExoPlayer HLS w/ `videoFileToken` for non-public media; watching reports every ~20 s verified server-side; resume position retrievable | +| 7 | Channel videos view (channel page) | done | Banner + avatar + name + video count header, paged video grid; opened from the Channels screen and the player's channel icon | + +## Discussed but deferred (2026-10-12, while reworking the player controls) + +- Like/dislike display: counts are in the video detail DTO; rating (own like via + `PUT /api/v1/videos/{uuid}/rating`) is a write op — display first, rating later. +- Subtitles: `GET /api/v1/videos/{uuid}/captions` → Media3 `SubtitleConfiguration` + on the player; language selection in the controls overlay. + +## Next up (v0.2+) + +UX-review findings (Claude Opus 5.5, 2026-10-10) — implemented already: focus + scroll +memory (player-as-overlay, per-destination grid state), 2-column grid, visible focus +(tv-material Cards with scale/border), relative dates on cards, privacy badge only on +My videos, password reveal + paste, overscan margins, sign-out entry, neutral form +focus colors, always-focusable Sign in with inline validation. Deferred from review: + +- "Continue watching" refinement: auto-remove finished entries, exact resume (start + playback at `userHistory.currentTime`, show "Resuming at 1:23" toast). +- Trending/featured row on Home; rows per subscribed channel (SmartTube-like). +- Video progress bars in ALL lists (needs per-video history merge; currently only + Continue-watching has them). +- Search-as-overlay vs sidebar entry; autoplay focus previews; playback speed; + auto-next after video end; subtitles (captions) support; phone-QR-assisted login. +- Drawer restructure proposal: Home / Channels (subs first) / Search / History / + Account — decide together with merged-feed item below. +- Unified "accessible videos" feed: merge public-local + own private into one paged, + deduplicated list (v0.1 keeps Home rows + My videos). +- Global local-channels browse (search endpoint rejects empty queries) — consider + `GET /api/v1/video-channels` paged without search + host filter, or `search/video-*`. +- Logout / switch account: sign-out entry exists; add switch-account (re-login screen + without losing scroll state). +- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. +- Thumbnails of private/internal videos may 404 — on some instances thumbnail + paths are publicly readable while on others they need auth; add a fallback + poster only if needed. +- Security note (verified live against tube.jeena.net): some instances serve + non-public media / thumbnails from object storage with only URL-based access + control. The app must not rely on that laxness; always send auth headers + / file tokens for non-public media. (Detail intentionally omitted from the + public repo — report instance-specific findings privately.) +- Verify `searchTarget=local` channel search behaves well for instances without + federation; consider `search/video-channels` endpoint as an alternative. +- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme. +- Verify `searchTarget=local` channel search behaves well for instances without + federation; consider `search/video-channels` endpoint as an alternative. +- (Verified 2026-10-10 via the test account) history subscriptions + watching endpoints + behave as documented; upload seeding uses **flat multipart form fields** on + `/api/v1/videos/upload` (legacy), not a metadata JSON part — only relevant for + seeding test data, the app itself doesn't upload. +- Error UX: friendlier messages (offline, instance down, wrong credentials vs. expired + token → re-login screen), retry buttons. +- i18n: move user-facing strings to `strings.xml`, consider German + English. +- Video detail screen (description, tags, views, duration, channel page link). + +## Longer term + +- Multiple saved instances / switching between them. +- Live streams playback (HLS live), premieres. +- Subtitles/captions selection. +- Downloads for offline watching (probably limited to public videos). +- Comments viewing (read-only) — needs moderation-safe UI thinking. +- `BACKLOG` triage rhythm: keep "Next up" ≤ 8 items. +- Out of scope by user request (do not implement without asking): uploading, editing, + creating channels/users. (Stale members of this list — subscribing, playlists + viewing/creation and rating — shipped with the player action row in v0.1; playlist + deletion was requested and added later, see below.) +- Delete own playlist: long-press OK on a playlist row (key-repeat) → confirmation + dialog → `DELETE /api/v1/video-playlists/{id}`; toggle stays on short press. Requested + 2026-10-13. \ No newline at end of file diff --git a/README.md b/README.md index f2add78..12c9cbf 100644 --- a/README.md +++ b/README.md @@ -4,91 +4,71 @@ The JTube TV logo, a little orange TV

-

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

+JTube TV is an Android TV app for [PeerTube](https://joinpeertube.org/). It puts +a PeerTube instance on the big screen the way SmartTube puts YouTube there: +browse from the couch, play with the remote, nothing else in the way. -JTube TV is an Android TV app for - 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 +It talks to any PeerTube instance over the [REST API](https://docs.joinpeertube.org/api-rest-reference.html). The default -in the sign-in screen is [tube.jeena.net](https://tube.jeena.net), the author's -own instance, but you can point it at yours. +is [tube.jeena.net](https://tube.jeena.net), my own instance, where most videos +are private family uploads. Signing in matters because "My videos" shows them +all, privacy levels included. ## What it looks like -The screenshots use a public demo instance with real, CC-licensed open-movie +Screenshots come from a demo instance serving real, CC-licensed open-movie content, courtesy of the [Blender Foundation](https://www.blender.org/) movies -and the [A propos de PeerTube](https://framatube.org) channel — all of it plays +and the [A propos de PeerTube](https://framatube.org) channel. All of it plays in the app. -**Home.** Three rows: *Continue watching* picks up each video at the position -the server saved, then the newest local videos, then the newest from the -channels you follow. Scroll right and more load on their own. +The home screen. Continue watching picks up at the position the server saved, +new videos first, page after page.

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

-**The menu.** A sidebar slides in — press the left D-pad at the edge of a row, -or just press Back from anywhere in a list. It jumps to Home, My videos, -Channels, Search, Subscriptions and History. +A slide-in drawer opens with the left D-pad button. My videos shows your own +uploads, every privacy level included, with a badge on anything not public.

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, subscriber and play counts on -every card. Open a channel for its banner, its stats and all its videos — and a -Subscribe button you can reach with the remote. +Channels, one row per owner, with video and play counts on every card.

Channels screen with channel cards

-**The player** speaks remote: play and pause, seek back and forward, a seek bar -with position and duration, and the video's info, comments and playlists a -button away. Watching is reported to the server every 20 seconds, so where you -left off survives closing the app. +The player speaks remote: play and pause, seek back and forward, a seek bar +with position and duration. Watching is reported to the server every 20 +seconds, so where you left off survives the app.

Player with controls, seek bar and title

-## What it does +## What it does in v0.1 -- **Sign in once.** The app trades your username and password for OAuth tokens - and keeps only those, encrypted with the Android Keystore. The password is - never stored. -- **Home** with Continue watching, new local videos and new videos from your - subscriptions. -- **Browse and search** the instance's public videos by title. -- **My videos** — every video you own, private, internal and unlisted included. -- **Channels** — local channels with their videos, plus your subscriptions and - your server-side watch history. -- **Subscribe** to and unsubscribe from channels, from the player or a channel - page. -- **Player** — HLS with a progressive MP4 fallback, resume from the saved - position, like/dislike, comments, playlists, and watching reports. +- Sign in once. The app exchanges your username and password for OAuth tokens + and stores only those, encrypted with the Android Keystore. The password + itself is never kept. +- Home with Continue watching and New videos. +- Browse the instance's public videos, and search them by title. +- My videos: every video you own, private, internal and unlisted included. +- Channels: local channels with their videos, plus your subscriptions and your + server-side watch history. +- Player: HLS, with a progressive MP4 fallback, resume from the saved + position, and watching reports. -What it deliberately does not do: uploading, editing or deleting content. +What it deliberately does not do (yet, or ever): uploading, editing, +deleting, managing subscriptions. That's tracked in +[BACKLOG.md](BACKLOG.md). ## Build @@ -122,6 +102,4 @@ A single-module app, deliberately small: ## License -JTube TV is free software under [GPL-3.0-or-later](LICENSE). The PeerTube logo -is a trademark of [Framasoft](https://framasoft.org/), used here to refer to the -PeerTube project. +JTube TV is free software under [GPL-3.0-or-later](LICENSE). \ 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 2c811d3..3a10e30 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,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository +import net.jeena.jtubetv.ui.list.Snapshots /** Where the list's content comes from. */ sealed interface ListSource { @@ -55,6 +56,16 @@ class VideoListViewModel( var videoTotal by androidx.compose.runtime.mutableStateOf(0L) private set + // Snapshot caching (issue #9): live content lists only — Search results and + // History rows (which carry extra watch metadata) must not persist. + private val shouldSkipSnapshot = + source is ListSource.Search || source is ListSource.History + + private fun cacheKey(): String = when (val s = source) { + is ListSource.Channel -> "channel-" + s.handle + else -> s::class.java.simpleName + } + init { // Probe whether the instance allows the search-index ("Vidiverse") scope. if (source is ListSource.Search) { @@ -63,10 +74,24 @@ class VideoListViewModel( repository.serverConfig().getOrNull()?.search?.searchIndex?.enabled == true } } - // No snapshot instant-paint any more (issue #25): the saved-list cache - // repainted stale rows that then jumped when the fresh page landed with - // newer videos. Screens now show the loading card until the first page - // arrives; images stay fast via Coil's own disk cache. + val context = AppGraph.appContext + if (context != null && !shouldSkipSnapshot) { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val videos = Snapshots.loadVideos(context, cacheKey()) ?: return@launch + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + showInstant(videos.map { VideoListItem.Video(it) }) + } + // Channel page banner: restore the saved one instantly too. + val s = source + if (s is ListSource.Channel) { + Snapshots.loadChannelHeader(context, s.handle)?.let { header -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + channelHeader = header + } + } + } + } + } } override fun onChunkLoaded(chunk: List) { @@ -78,6 +103,20 @@ class VideoListViewModel( } } ) + // Persist the freshly loaded content so the next app start paints + // instantly (issue #9). + if (!shouldSkipSnapshot && chunk.all { it is VideoListItem.Video }) { + val context = AppGraph.appContext + if (context != null) { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveVideos( + context, + cacheKey(), + items.filterIsInstance().take(50).map { it.video }, + ) + } + } + } } override suspend fun fetch(start: Long, count: Int): Result> { @@ -111,9 +150,22 @@ class VideoListViewModel( fun loadChannelHeader(repository: PeerTubeRepository) { val s = source if (s is ListSource.Channel) { + // Instant restore from the snapshot, then a fresh fetch (issue #9). + val context = AppGraph.appContext + if (context != null) { + Snapshots.loadChannelHeader(context, s.handle)?.let { snap -> + if (channelHeader == null) channelHeader = snap + } + } viewModelScope.launch { repository.videoChannel(s.handle).getOrNull()?.let { header -> channelHeader = header + val ctx = AppGraph.appContext + if (ctx != null) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelHeader(ctx, s.handle, header) + } + } } } } 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 6ca82bb..ced63ff 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,10 +4,6 @@ import android.view.KeyEvent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -97,16 +93,15 @@ fun PlayerSidebar( .width(SIDEBAR_WIDTH) .background(SidebarBg), ) { - // Header = the video's title (issue #23/#24): the old generic "Info" / - // "Comments" label read as an empty black bar with no context about - // which video the panel belongs to. Playlists keeps its own label. Text( - text = if (mode == "playlists") "Playlists" else detail.name, + text = when (mode) { + "playlists" -> "Playlists" + "info" -> "Info" + else -> "Comments" + }, color = Color.White, style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 10.dp), + modifier = Modifier.padding(20.dp), ) when (mode) { "playlists" -> PlaylistsColumn(detail, onDismiss) @@ -166,84 +161,68 @@ 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() } - // 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() + LaunchedEffect(links) { + if (links.isNotEmpty()) { + kotlinx.coroutines.delay(150) + runCatching { firstLinkFocus.requestFocus() } } } - Column(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize()) { if (description.isNotBlank()) { - DescriptionScroll( - annotated = annotated, - focusRequester = descFocus, - onDismiss = onDismiss, - onLeaveBottom = { - if (links.isNotEmpty()) { - runCatching { firstLinkFocus.requestFocus() }.isSuccess - } else false - }, - modifier = Modifier.weight(1f, fill = false), - ) + 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), + ) + } } if (links.isNotEmpty()) { - 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, - ) - } + 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, + ) } } } @@ -251,64 +230,6 @@ private fun InfoColumn( } } -/** - * Focusable, D-pad-scrollable description (issue #23). On TV a plain Text in a - * LazyColumn can't be scrolled with the remote — nothing in it takes focus — so - * long descriptions were unreadable past the first screenful. This box takes - * focus and scrolls on UP/DOWN; LEFT closes the panel; DOWN at the bottom hands - * focus to the link list (if any). - */ -@Composable -private fun DescriptionScroll( - annotated: androidx.compose.ui.text.AnnotatedString, - focusRequester: FocusRequester, - onDismiss: () -> Unit, - onLeaveBottom: () -> Boolean, - modifier: Modifier = Modifier, -) { - val scroll = androidx.compose.foundation.rememberScrollState() - val scope = rememberCoroutineScope() - var focused by remember { mutableStateOf(false) } - var viewportPx by remember { mutableStateOf(0) } - Box( - modifier - .fillMaxWidth() - .onFocusChanged { focused = it.isFocused } - .focusRequester(focusRequester) - .focusable() - .background(if (focused) Color(0x0DFFFFFF) else Color.Transparent) - .onPreviewKeyEvent { ke -> - if (ke.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - val step = (viewportPx * 0.85f).coerceAtLeast(240f) - when (ke.nativeKeyEvent.keyCode) { - KeyEvent.KEYCODE_DPAD_DOWN -> - if (scroll.value < scroll.maxValue) { - scope.launch { scroll.animateScrollBy(step) } - true - } else onLeaveBottom() - KeyEvent.KEYCODE_DPAD_UP -> - if (scroll.value > 0) { - scope.launch { scroll.animateScrollBy(-step) } - true - } else true // nothing above the description; stay put - KeyEvent.KEYCODE_DPAD_LEFT -> { - onDismiss(); true - } - else -> false - } - } - .verticalScroll(scroll) - .onSizeChanged { viewportPx = it.height }, - ) { - Text( - text = annotated, - color = TextMain, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), - ) - } -} - /** Link click listener: the annotation callback shape. */ 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 131a64d..203fbf7 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,7 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -64,9 +63,9 @@ import net.jeena.jtubetv.data.api.ChannelStats import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.VideoChannelDto import net.jeena.jtubetv.data.api.VideoDto +import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape -import net.jeena.jtubetv.ui.screens.list.ShimmerAsyncImage import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable @@ -94,6 +93,10 @@ data class UserGroup( class ChannelsViewModel(private val store: TokenStore) : ViewModel() { + private val snapshotContext = AppGraph.appContext + private val snapshotKey = "channels-" + (store.baseUrl ?: "").replace(Regex("[^A-Za-z0-9._-]"), "_") + private val snapshotRows = snapshotContext?.let { Snapshots.loadUserGroups(it, snapshotKey) } + var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) @@ -123,9 +126,20 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { /** SystemClock at the last successful fast-path pass — re-entry freshness. */ private var lastLoadedAt = 0L - // No snapshot instant-paint any more (issue #25): the Channels screen shows - // its shimmer placeholder rows until the (fast-path, few-request) load lands, - // instead of painting a stale snapshot that reshuffled as stats streamed in. + init { + // Snapshot restore (issue #9): paint the last session's channel rows + // instantly, including the per-channel stats (the "minutes of loading" + // complaint) — the streaming reload below then updates them in place. + if (snapshotRows != null) { + userRows = snapshotRows + loading = false + } + if (snapshotContext != null) { + statsMap.putAll(Snapshots.loadChannelStats(snapshotContext, snapshotKey)) + if (statsMap.isNotEmpty()) stats = statsMap.toMap() + } + } + fun load(force: Boolean = false) { // Re-entry (destination switch away + back) inside the freshness window: @@ -210,6 +224,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { stats = statsMap.toMap() rebuildRows(host) lastLoadedAt = android.os.SystemClock.elapsedRealtime() + snapshotContext?.let { ctx -> + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveChannelStats(ctx, snapshotKey, statsMap.toMap()) + } + } ImagePrefetcher.channelArt(allChannels + userChannels) } @@ -283,6 +302,12 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, ) + // Persist for the next cold start (issue #9). + snapshotContext?.let { ctx -> + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveUserGroups(ctx, snapshotKey, userRows) + } + } Log.w( "JTube", "order now: " + userRows.take(6).joinToString(" | ") { g -> @@ -414,7 +439,6 @@ private fun absoluteAvatar(avatar: Avatar): String = } /** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ -@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable private fun ChannelRow( channels: List, @@ -457,8 +481,6 @@ private fun ChannelRow( LazyRow( state = rowState, - // Land on the last-focused card when moving between rows (issue #29). - modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -531,7 +553,7 @@ fun ChannelCard( val coverUrl = banner?.let { absoluteBanner(it) } ?: stats?.latestThumbUrl if (coverUrl != null) { - ShimmerAsyncImage( + AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -545,7 +567,7 @@ fun ChannelCard( } val avatar = channel.avatars.maxByOrNull { it.width } if (avatar != null) { - ShimmerAsyncImage( + AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) @@ -554,12 +576,12 @@ fun ChannelCard( .build(), contentDescription = null, contentScale = ContentScale.Crop, - base = Color(0xFF333333), modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) .size(40.dp) - .clip(CircleShape), + .clip(CircleShape) + .background(Color(0xFF333333)), ) } } 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 7ebda20..bca62ff 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,7 +14,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,6 +40,7 @@ import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.list.Snapshots import net.jeena.jtubetv.ui.screens.list.ShimmerCard import net.jeena.jtubetv.ui.screens.list.VideoCard @@ -57,6 +57,8 @@ data class ContinueItem(val video: VideoDto, val progress: Float?) class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel() { + private fun contextOrNull() = AppGraph.appContext + var continueItems by mutableStateOf>(emptyList()) private set var continueLoading by mutableStateOf(true) @@ -73,24 +75,36 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( private var continueInFlight = false var continueExhausted by mutableStateOf(false) private set - /** True while the next continue page is loading — drives a trailing loading card (#25). */ - var continueLoadingMore by mutableStateOf(false) - private set private var subsStart = 0L private var subsInFlight = false var subsExhausted by mutableStateOf(false) private set - var subsLoadingMore by mutableStateOf(false) - private set /** Exposed for the shimmer placeholders (issue #19). */ var subsLoading = false private set val recent = RecentVideosViewModel(repository) - // No snapshot instant-paint any more (issue #25): Home shows one loading card - // per row until the network pages land, instead of painting a stale snapshot - // that then jumped as newer videos arrived. + init { + // Snapshot restore (issue #9): Home paints from the last session's rows + // immediately; the loads below freshen them from the network. + val context = contextOrNull() + if (context != null) { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val savedContinue = Snapshots.loadContinue(context, "home-continue") + val savedSubs = Snapshots.loadVideos(context, "home-subs") + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + if (continueItems.isEmpty() && savedContinue != null) { + continueItems = savedContinue + continueLoading = false + } + if (subItems.isEmpty() && savedSubs != null) { + subItems = savedSubs + } + } + } + } + } private fun fetchSubs() { viewModelScope.launch { @@ -101,6 +115,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( subItems = subs subsLoading = false net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(subs) + // Snapshot for the next cold start (issue #9). + contextOrNull()?.let { ctx -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveVideos(ctx, "home-subs", subs) + } + } } } @@ -156,7 +176,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( fun continueMore() { if (continueInFlight || continueExhausted || continueItems.isEmpty()) return continueInFlight = true - continueLoadingMore = true viewModelScope.launch { val page = fetchContinuePage() if (page.isNotEmpty()) { @@ -164,7 +183,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page.map { it.video }) } continueInFlight = false - continueLoadingMore = false } } @@ -172,7 +190,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( fun subsMore() { if (subsInFlight || subsExhausted || subItems.isEmpty()) return subsInFlight = true - subsLoadingMore = true viewModelScope.launch { val page = repository.subscriptionVideos(subsStart, SUB_PAGE).getOrNull()?.data ?: emptyList() subsStart += page.size @@ -182,7 +199,6 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(page) } subsInFlight = false - subsLoadingMore = false } } @@ -194,6 +210,12 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( continueItems = items continueLoading = false net.jeena.jtubetv.ui.list.ImagePrefetcher.videoThumbnails(items.map { it.video }) + // Snapshot for the next cold start (issue #9). + contextOrNull()?.let { ctx -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + Snapshots.saveContinue(ctx, "home-continue", items) + } + } } } @@ -213,8 +235,28 @@ class HomeRowsViewModel(private val repository: PeerTubeRepository) : ViewModel( /** Paged "New videos" row content. */ class RecentVideosViewModel(private val repository: PeerTubeRepository) : PagedListViewModel() { + // 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> = @@ -272,10 +314,9 @@ fun HomeRowsScreen( } item { if (viewModel.continueLoading && viewModel.continueItems.isEmpty()) { - // One placeholder card per row while it loads (#25): a single card - // reads as "this row is coming", not a fake full row of content. + // Dummy cards with the faint shimmer while the row loads (#19); // "Watch something first" only when there is genuinely no history. - ShimmerRow(size = 1) + ShimmerRow() } else if (viewModel.continueItems.isEmpty()) { Box(Modifier.fillParentMaxWidth(0f).padding(start = 16.dp)) { Text( @@ -297,7 +338,6 @@ fun HomeRowsScreen( autoFocusOnLoad = true, autoFocusAllowed = { !userNavigated }, refocusPulse = drawerPulse, - trailingLoading = viewModel.continueLoadingMore, ) { index, video -> // Grow the row on demand (issue #18), same hook as the recent row. if (index >= viewModel.continueItems.size - PagedListViewModel.LOOKAHEAD) { @@ -317,7 +357,7 @@ fun HomeRowsScreen( item { if (recentItems.isEmpty()) { LaunchedEffect("kick-recent") { viewModel.recent.loadMore() } - ShimmerRow(size = 1) + ShimmerRow() } else { FocusScrollRow( videos = recentItems, @@ -327,7 +367,6 @@ fun HomeRowsScreen( clickedIndex = recentRestore, restoreToken = restoreToken, refocusPulse = drawerPulse, - trailingLoading = viewModel.recent.loadingMore, ) { index, video -> if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.recent.loadMore() } @@ -346,7 +385,7 @@ fun HomeRowsScreen( } item { if (viewModel.subItems.isEmpty() && viewModel.subsLoading) { - ShimmerRow(size = 1) + ShimmerRow() } else { FocusScrollRow( videos = viewModel.subItems, @@ -356,7 +395,6 @@ fun HomeRowsScreen( clickedIndex = subRestore, restoreToken = restoreToken, refocusPulse = drawerPulse, - trailingLoading = viewModel.subsLoadingMore, ) { index, video -> if (index >= viewModel.subItems.size - PagedListViewModel.LOOKAHEAD) { LaunchedEffect(index) { viewModel.subsMore() } @@ -386,7 +424,6 @@ fun ShimmerRow(size: Int = 4) { * viewport, the row animates it to the left edge (SmartTube behaviour) instead of * the abrupt jump the built-in lazy scroll produces. */ -@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable fun FocusScrollRow( videos: List, @@ -405,9 +442,6 @@ fun FocusScrollRow( // NOTE: stays before [afterItem] — the trailing lambda binds to the last // declared parameter. refocusPulse: Int = 0, - // A trailing loading card at the row's end while the next page loads (#25), - // so it's clear more is coming when scrolling right. - trailingLoading: Boolean = false, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -455,10 +489,6 @@ fun FocusScrollRow( } LazyRow( state = rowState, - // Jumping between rows lands on the card this row last had focus on, not - // the geometrically-nearest one (issue #29): the rows stop shifting - // left/right as you move up and down between them. - modifier = Modifier.focusRestorer { focusRequesters[focusedIndex.coerceAtLeast(0)] ?: FocusRequester.Default }, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -482,14 +512,5 @@ fun FocusScrollRow( ) afterItem(index, video) } - if (trailingLoading) { - item(key = "trailing-loading") { - ShimmerCard( - modifier = Modifier - .padding(horizontal = 4.dp) - .width(rowCardWidth()), - ) - } - } } } 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 019e2ff..e14554d 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,15 +114,19 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } } - // BACK order (issue #26): the sidebar is the root of a list view, reached - // with one BACK press instead of scrolling all the way left. - // player → its own handler - // drawer open, Home → double-press exits (the sidebar is the root) - // drawer open, else → close the drawer - // channel drilldown → back up to the list that opened it - // any list view → open the sidebar + // BACK order (TV standard): player (own handler) → drawer close → channel + // drilldown → non-Home destination → Home; from Home: double-press exits. BackHandler(enabled = drawerOpen && playingVideo == null) { - if (destination == Destination.Home && channelDetails == null) { + dismissDrawer() + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { + channelDetails = null + } + BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) { + if (destination != Destination.Home) { + channelDetails = null + destination = Destination.Home + } else { val now = System.currentTimeMillis() if (now - lastBackAt < 2500) { (context as? android.app.Activity)?.finish() @@ -130,16 +134,8 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() - } else { - dismissDrawer() } } - BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) { - channelDetails = null - } - BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) { - drawerOpen = true - } Box( modifier = Modifier @@ -155,9 +151,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi when { drawerOpen -> { dismissDrawer(); true } channelDetails != null -> { channelDetails = null; true } - // Mirror the D-pad BACK model (issue #26): Esc opens the - // sidebar from a top-level list view too. - else -> { drawerOpen = true; true } + else -> false } } else false }, 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 0a1c3a2..d80fa37 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() - ShimmerAsyncImage( + AsyncImage( model = req, contentDescription = video.name, contentScale = ContentScale.Crop, @@ -320,48 +320,6 @@ fun ShimmerCard(modifier: Modifier = Modifier) { } } -/** - * Coil image that shows the same faint shimmer as [ShimmerCard] while it loads - * (issue #28) — thumbnails, channel banners and avatars no longer pop in from a - * flat grey rectangle. The sweep runs only while loading: once the image (or an - * error) arrives, the overlay leaves composition and its animation disposes. - */ -@Composable -fun ShimmerAsyncImage( - model: Any?, - contentDescription: String?, - modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop, - base: Color = Color(0xFF222222), -) { - var loaded by remember(model) { androidx.compose.runtime.mutableStateOf(false) } - Box(modifier.background(base)) { - AsyncImage( - model = model, - contentDescription = contentDescription, - contentScale = contentScale, - modifier = Modifier.matchParentSize(), - onState = { state -> - loaded = state is coil.compose.AsyncImagePainter.State.Success || - state is coil.compose.AsyncImagePainter.State.Error - }, - ) - if (!loaded) ShimmerFill(Modifier.matchParentSize(), base) - } -} - -/** Solid base + sweeping sheen; owns its own transition so it disposes on removal. */ -@Composable -private fun ShimmerFill(modifier: Modifier, base: Color) { - val sweep = rememberInfiniteTransition() - val x by sweep.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing)), - ) - Box(modifier.background(base).shimmer(x)) -} - /** Very faint metallic sheen sweeping per [progress] (0..1); barely stronger than the base. */ fun Modifier.shimmer(progress: Float): Modifier = drawBehind { drawRect( 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 cedb561..571e02f 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,11 +1,8 @@ package net.jeena.jtubetv.ui.screens.list import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.VideoLibrary -import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -162,29 +159,22 @@ fun VideoListScreen( return } - androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) { - // Channel page: the header is PINNED above the grid (issue #27) rather than - // scrolling away as the first grid item — auto-focusing the first video card - // no longer hides the banner, and the Subscribe button on it stays reachable. - if (source is ListSource.Channel) { - ChannelHeader( - channelHeader = viewModel.channelHeader, - videoTotal = viewModel.videoTotal, - handle = source.handle, - onLeftEdge = onOpenDrawer, - ) - } LazyVerticalGrid( // Same card size as the Home rows: ~4 fit per row (user request). columns = GridCells.Adaptive(rowCardWidth() - 12.dp), state = gridState, - modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp), + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { - // Search field still scrolls with its results (its own header item); - // the channel banner is pinned above the grid instead (issue #27). - if (source is ListSource.Search) { - item(key = "page-header", span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - SearchHeader(viewModel, onOpenDrawer) + // 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 -> {} } } if (source is ListSource.Search && items.isEmpty() && !viewModel.loadingInitial) { @@ -249,18 +239,21 @@ fun VideoListScreen( } } if (viewModel.loadingMore) { - // Growing the list: a single loading card at the end (#25) so it's - // clear more is coming, without faking a whole row of content. - item(key = "load-more-placeholder") { - ShimmerCard( - modifier = Modifier - .padding(6.dp) - .width(rowCardWidth() - 12.dp), - ) + // 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()), + ) + } + } } } } - } } @Composable @@ -403,216 +396,95 @@ private fun ScopeChip( } } -/** - * Channel page header (issue #27): the banner is the backdrop of a fixed-height - * header, darkened with a bottom-up gradient, with the channel identity + stats - * and a reachable Subscribe button laid over it. It's pinned above the grid so - * it never scrolls away, and the video cards start directly beneath it. - */ +/** Channel page header: banner strip, then avatar + display name + handle, + * a metadata strip (subscribers/videos/created + owner, issue #14) and the + * channel description snippet above the videos. */ @Composable private fun ChannelHeader( channelHeader: net.jeena.jtubetv.data.api.VideoChannelDto?, videoTotal: Long = 0, - handle: String, - onLeftEdge: () -> Unit = {}, ) { if (channelHeader == null) return - val metaColor = Color(0xFFCFCFCF) - val 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)), - ) { + val metaColor = Color(0xFF9A9A9A) + androidx.compose.foundation.layout.Column { + val banner = channelHeader.banners.maxByOrNull { it.width } if (banner != null) { - ShimmerAsyncImage( + AsyncImage( model = AppGraph.repository.absoluteUrl(banner.fileUrl ?: banner.path), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)), ) } - // Darkening overlay, fading to solid black at the bottom so the banner - // blends seamlessly into the description strip beneath it. - Box( - Modifier - .matchParentSize() - .background( - androidx.compose.ui.graphics.Brush.verticalGradient( - 0f to Color(0x33000000), - 0.5f to Color(0x99000000), - 1f to Color(0xFF000000), - ), - ), - ) - // Identity + stats, bottom-left. The avatar sits beside the title (as it - // did before the redesign — user request). - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 16.dp, end = 16.dp, bottom = 10.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val avatar = channelHeader.avatars.maxByOrNull { it.width } - if (avatar != null) { - ShimmerAsyncImage( - 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, - ) - } - } + 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), + ) } - 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) - } + 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, + ) } } - // 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 + // Metadata strip between the identity and the videos (issue #14): the + // counts SmartTube shows, plus creation date and the owning user. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(start = 16.dp, top = 8.dp), + ) { + headerStat(icon = Icons.Filled.Groups, value = channelHeader.followersCount.toString() + " subscribers", color = metaColor) + val videoCount = videoTotal.takeIf { it > 0 } ?: channelHeader.videosCount + if (videoCount > 0) { + headerStat(icon = Icons.Filled.VideoLibrary, value = videoCount.toString() + " videos", color = metaColor) } - .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 - } + if (channelHeader.createdAt != null) { + headerStat(icon = Icons.Filled.CalendarMonth, value = "created " + formatAge(channelHeader.createdAt), color = metaColor) } - .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, - ) + } + 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), + ) + } } } diff --git a/docs/peertube-logo.svg b/docs/peertube-logo.svg deleted file mode 100644 index 4f07ef9..0000000 --- a/docs/peertube-logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/docs/screenshots/channels.png b/docs/screenshots/channels.png index f88dc4e..11d2afe 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 5559078..7b27e6c 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 1e150b8..5a2d6dd 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 5321a83..c0b1af9 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 f722916..303e0c0 100644 Binary files a/docs/screenshots/player.png and b/docs/screenshots/player.png differ