diff --git a/.gitignore b/.gitignore index f89a0ab..8685299 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ /app/build /captures .externalNativeBuild -.cxxreports/*-shots/ +.cxx +reports/*-shots/ +reports/*.md diff --git a/AGENTS.md b/AGENTS.md index 1e68716..bc283be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a whose media lives in a private object-storage bucket — pass the short-lived `POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs (response shape: `{ files: { token, expires } }`; verified live). - - tube.jeena.net itself streams non-public media from a **publicly readable R2 - bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on - that — always request the file token for non-public videos. + - Some instances stream non-public media from object-storage hosts with only + URL-based access control — never rely on that; always request and use the + file token for non-public videos. (Instance-specific findings stay out of + the public repo and live in the private ops notes/agent-local instructions.) ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 08cad82..5ac444d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev - 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 — verified on tube.jeena.net: - instance thumbnail paths are publicly readable (HTTP 200 without auth); add a - fallback poster only if other instances behave differently. -- Security note for tube.jeena.net: object-storage (R2) media for private test - videos is publicly readable without token ("private" only via URL obscurity). - Flag to the instance admin; the app must not rely on this laxness. +- 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. diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 5079c0d..8922c39 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,14 +125,20 @@ class PeerTubeRepository( var videos = 0L var plays = 0L var total: Long? = null + var latestThumb: String? = null repeat(maxPages) { val page = network.api.channelVideos(handle, start, 25) - if (total == null) total = page.total + if (total == null) { + total = page.total + latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path -> + if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/') + } + } plays += page.data.sumOf { it.views } start += page.data.size videos = total ?: videos } - net.jeena.jtubetv.data.api.ChannelStats(videos, plays) + net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb) } private fun unauthenticatedApiFor(base: String): PeerTubeApi = @@ -178,6 +184,9 @@ class PeerTubeRepository( response.data to response.total } + suspend fun videoChannel(handle: String): Result = + runCatching { network.api.videoChannel(handle) } + suspend fun accountChannels(username: String): Result> = runCatching { val response: VideoChannelListResponse = network.api.accountChannels(username) diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index b807ebc..91d3777 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -158,6 +158,8 @@ data class InstanceInfoDto( data class ChannelStats( val videos: Long, val plays: Long, + /** Thumbnail of the newest video — banner fallback for channels without one. */ + val latestThumbUrl: String? = null, ) @Serializable diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 4c505ce..1c5940a 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -83,6 +83,11 @@ interface PeerTubeApi { @Path("account") account: String, ): VideoChannelListResponse + @GET("video-channels/{handle}") + suspend fun videoChannel( + @Path("handle") handle: String, + ): VideoChannelDto + @GET("video-channels/{handle}/videos") suspend fun channelVideos( @Path("handle") handle: String, 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 33bf4aa..a723e8e 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 @@ -1,8 +1,13 @@ package net.jeena.jtubetv.ui.list +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository @@ -30,6 +35,12 @@ class VideoListViewModel( val query = MutableStateFlow("") private var activeSearch: String? = null + /** For channel pages: the channel itself (banner + display name) as page header. */ + var channelHeader by androidx.compose.runtime.mutableStateOf( + null, + ) + private set + override fun onChunkLoaded(chunk: List) { ImagePrefetcher.videoThumbnails( chunk.mapNotNull { @@ -60,6 +71,15 @@ class VideoListViewModel( } } + fun loadChannelHeader(repository: PeerTubeRepository) { + val s = source + if (s is ListSource.Channel) { + viewModelScope.launch { + channelHeader = repository.videoChannel(s.handle).getOrNull() + } + } + } + fun triggerSearch(raw: String) { query.value = raw activeSearch = raw.trim().ifBlank { null } 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 707db29..a97a119 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 @@ -1,14 +1,9 @@ package net.jeena.jtubetv.ui.screens +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -33,65 +35,64 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.foundation.BorderStroke import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import coil.compose.AsyncImage +import android.util.Log +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.api.Avatar +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.ui.list.ImagePrefetcher import net.jeena.jtubetv.ui.screens.list.ContainerShape import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg - -/** One row of channels belonging to one user (avatar + display name as header). */ -data class UserGroup( - val label: String?, - val avatar: Avatar?, - val videos: Long, - val rows: List, -) { - /** Sort weight: users with videos first (more videos first), empty channels last. */ - fun rowWeight(): Long = if (videos > 0) videos else Long.MIN_VALUE / 2 -} +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** - * Channels as SmartTube-style rows: - * 1. "Your channels" — header with account avatar + username, then own channel cards. - * 2. "Local channels" — the instance's own channels. The public API has NO - * local-only filter (verified in v8.3.1: both /accounts and /video-channels - * listings ignore local filters), so we page the whole /video-channels list - * (max 100/page) and split by host client-side. - * 3. "Followed instances" — channels of instances this server follows. + * Channels, SmartTube-style: one row per LOCAL user. + * + * - Row 1: the logged-in user ("'s Channels"), then the other local + * users sorted by videos / subscribers / plays (desc). + * - Each row: that user's channels, same ordering; channels with no videos simply + * stay inside their user's row. + * - Followed-instance channels at the end, grouped per platform. + * + * API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings + * have no isLocal filter and the listing carries no videosCount/views, so we page + * the full listing (max 100/page) and fetch per-channel stats (videos + sum of + * video views = plays) with small throttled calls. */ +data class UserGroup( + val ownerName: String, + val label: String, + val avatar: Avatar?, + val channels: List, +) + class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var user by mutableStateOf(null) private set var userChannels by mutableStateOf>(emptyList()) private set - var localChannels by mutableStateOf>(emptyList()) - private set - /** One row per (local) user, sorted by total video count; empties last. */ - var localUserRows by mutableStateOf>(emptyList()) - private set - - /** Channels from instances this server follows, grouped by their instance. */ - var followedRows by mutableStateOf>(emptyList()) + var userRows by mutableStateOf>(emptyList()) private set var loading by mutableStateOf(true) @@ -99,87 +100,72 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> videos + plays; filled in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> stats; filled progressively in the background. */ + var stats by mutableStateOf>(emptyMap()) private set /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ var platformNames by mutableStateOf>(emptyMap()) private set + private var allChannels: List = emptyList() + + /** Channels from followed instances, grouped per platform. */ + var followedRows by mutableStateOf>(emptyList()) + private set + private val repo: PeerTubeRepository = AppGraph.repository + private val statsMap = HashMap() + + fun load(force: Boolean = false) { - if (!force && userChannels.isNotEmpty()) return + if (!force && userRows.isNotEmpty()) return loading = true error = null viewModelScope.launch { try { + // The user's own row renders FIRST (fast: one call), the rest streams in. user = repo.me().getOrNull() userChannels = user?.let { u -> repo.accountChannels(u.username).getOrNull() ?: emptyList() } ?: emptyList() + val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') + platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: "")) + // enum the whole channel list (federation-follow listing incl. local) val all = mutableListOf() var start = 0L var total: Long? = null + var retries = 0 while (total == null || start < total) { - val page = repo.channelList(start, 100).getOrNull() ?: break + val page = repo.channelList(start, 100).getOrNull() + if (page == null) { + retries++ + if (retries > 2) break + delay(500) + continue + } + retries = 0 if (total == null) total = page.second val known = all.map { it.name to it.host }.toSet() all += page.first.filter { c -> (c.name to c.host) !in known } start += 100 + android.util.Log.w( + "JTube", + "channels page " + (start / 100) + ": total=" + total + " all=" + all.size + + " locals=" + all.count { it.host == host }, + ) + allChannels = all + rebuildRows(host) } + ImagePrefetcher.channelArt(all + userChannels) - val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') - val ownNames = userChannels.map { c -> c.name }.toSet() - val mine = all.filter { it.host == host && it.name in ownNames } - val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } - userChannels = (userChannels + mine).distinctBy { it.name to it.host } - localChannels = rest.filter { it.host == host } - - net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) - - // platform display names (local cached in prefs; foreign lazy) - platformNames = mapOf( - (host ?: "") to (store.instanceName ?: host ?: ""), - ) - val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "") - for (fh in foreignHosts) { - val name = repo.instanceName(fh).getOrNull() ?: continue - platformNames = platformNames + (fh to name) - } - - // video + play counts per channel, one tiny listing call per channel - for (channel in (userChannels + localChannels)) { - val key = channel.name + "@" + channel.host - val cs = repo.channelStats(key).getOrNull() ?: continue - stats = stats + (key to cs) - } - // group LOCAL channels by owner (user): row per user, videos desc, - // channels with zero videos collected into a single last row. - val locals = rest.filter { it.host == host } - val withVideos = locals.filter { it.videosCount > 0 } - val empty = locals.filter { it.videosCount <= 0 } - val byOwner = withVideos.groupBy { ownerKey(it) } - localUserRows = - byOwner.values - .map { row -> UserGroup( - label = rowLabel(row), - avatar = rowAvatar(row), - videos = rowTotal(row), - rows = row.sortedByDescending { it.videosCount }, - ) } - .filter { it.label != null } - .sortedByDescending { it.rowWeight() } - if (empty.isNotEmpty()) { - localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty) - } - followedRows = rest.filter { it.host != host } - .groupBy { it.host ?: "?" } - .values - .map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) } - .sortedByDescending { it.rowWeight() } + // stats (videos + plays) for every local channel: throttled parallel-ish + val locals = all.filter { it.host == host } + Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + + " userChannels=" + userChannels.size) + channelStatsLoop(locals, host) } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -188,11 +174,84 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" - private fun rowLabel(row: List) = row.firstOrNull()?.ownerAccount?.displayName - private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } - private fun rowTotal(row: List) = row.sumOf { it.videosCount } + /** Fetch stats for [channels], re-sorting rows as data lands. */ + private fun channelStatsLoop(channels: List, host: String?) { + viewModelScope.launch { + for (channel in channels) { + val key = channel.name + "@" + channel.host + if (statsMap.containsKey(key)) continue + val cs = repo.channelStats(key).getOrNull() + if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue } + android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays) + statsMap[key] = cs + stats = statsMap.toMap() + rebuildRows(host) + delay(120) + } + } + } + private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L + private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L + + /** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */ + private fun rebuildRows(host: String?) { + runCatching { rebuildRowsInner(host) }.onFailure { + android.util.Log.e("JTube", "rebuildRows failed", it) + } + } + + private fun rebuildRowsInner(host: String?) { + val locals = allChannels.filter { it.host == host } + val foreign = allChannels.filter { it.host != host } + followedRows = foreign + .groupBy { it.host ?: "?" } + .values + .map { row -> + UserGroup( + ownerName = row.firstOrNull()?.host ?: "?", + label = (platformNames[row.firstOrNull()?.host] + ?: row.firstOrNull()?.host ?: "?") + " (followed)", + avatar = null, + channels = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount }, + ), + ) + } + .sortedByDescending { g -> g.channels.sumOf { it.followersCount } } + val ownNames = userChannels.map { c -> c.name }.toSet() + val byOwner = locals.groupBy { ownerKey(it) } + val groups = byOwner.values.map { row -> + val sorted = row.sortedWith( + compareByDescending { statOf(it) } + .thenByDescending { it.followersCount } + .thenByDescending { playsOf(it) }, + ) + val own = row.firstOrNull { it.name in ownNames } + UserGroup( + ownerName = ownerKey(row.first()), + label = ((own?.ownerAccount?.displayName ?: row.firstOrNull()?.ownerAccount?.displayName) ?: "?") + "'s Channels", + avatar = (own?.ownerAccount ?: row.first().ownerAccount)?.avatars?.maxByOrNull { it.width }, + channels = sorted, + ) + } + userRows = groups.sortedWith( + compareByDescending { g -> g.channels.any { it.name in ownNames } } + .thenByDescending { g -> g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } } + .thenByDescending { g -> g.channels.sumOf { it.followersCount } }, + ) + Log.w( + "JTube", + "order now: " + userRows.take(6).joinToString(" | ") { g -> + g.ownerName + "(" + g.channels.size + "ch, " + + g.channels.sumOf { statsMap[it.name + "@" + it.host]?.videos ?: 0 } + "v" + + (if (g.channels.any { it.name in ownNames }) ", OWN" else "") + ")" + }, + ) + } + + private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "" companion object { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -212,11 +271,12 @@ fun ChannelsScreen( ) { LaunchedEffect(epoch) { viewModel.load(force = true) } + when { - viewModel.loading && viewModel.localChannels.isEmpty() -> Box( + viewModel.loading && viewModel.userRows.isEmpty() -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { androidx.compose.material3.CircularProgressIndicator() } - viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> + viewModel.error != null && viewModel.userRows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Could not load channels", color = MaterialTheme.colorScheme.error) @@ -231,60 +291,28 @@ fun ChannelsScreen( } } else -> { - val lastOpened = remember { mutableIntStateOf(-1) } - var focusedRow by remember { mutableIntStateOf(0) } - - androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { - if (viewModel.userChannels.isNotEmpty()) { - item { - RowHeader( - avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, - label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", - ) - } - item { - ChannelRow( - viewModel.userChannels, - onOpenChannel, - onOpenDrawer, - firstFocused = true, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.localUserRows.forEach { group -> - item { + // hand the initial focus to the first row exactly ONCE per screen visit: + // as stats stream in the rows re-sort, and re-focusing their first card + // would steal focus mid-navigation (user: "up jumps to the first row"). + val focusHandedOut = remember { mutableStateOf(false) } + LazyColumn(Modifier.fillMaxSize()) { + viewModel.userRows.forEach { group -> + item(key = "hdr-" + group.ownerName) { RowHeader( avatar = group.avatar, - label = (group.label ?: "?") + " (" + group.videos + ")", + label = group.label, ) } - item { + item(key = "row-" + group.ownerName) { ChannelRow( - group.rows, + group.channels, onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, - ) - } - } - - viewModel.followedRows.forEach { group -> - item { - RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")") - } - item { - ChannelRow( - group.rows, - onOpenChannel, - {}, - firstFocused = false, - statsProvider = { viewModel.stats[it.name + "@" + it.host] }, - platformProvider = { viewModel.platformNames[it.host ?: ""] }, + onOpenDrawer, + focused = !focusHandedOut.value && + group == viewModel.userRows.firstOrNull(), + onFocused = { focusHandedOut.value = true }, + stats = { viewModel.stats[it.name + "@" + it.host] }, + platformName = { viewModel.platformNames[it.host ?: ""] }, ) } } @@ -293,9 +321,9 @@ fun ChannelsScreen( } } -/** Row header: user icon + username, or plain label. */ +/** Row header: user icon + "'s Channels". */ @Composable -private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { +private fun RowHeader(avatar: Avatar?, label: String) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), @@ -303,7 +331,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = ) { if (avatar != null) { AsyncImage( - model = absolute(avatar.fileUrl ?: avatar.path), + model = coil.request.ImageRequest.Builder( + androidx.compose.ui.platform.LocalContext.current, + ) + .data(absoluteAvatar(avatar)) + .size(80) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -316,24 +349,26 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = } } -private fun absolute(pathOrUrl: String): String = - if (pathOrUrl.startsWith("http")) pathOrUrl - else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl +private fun absoluteAvatar(avatar: Avatar): String = + (avatar.fileUrl ?: avatar.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } -/** Horizontal row of channel cards; eases newly focused cards into view. */ +/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ @Composable private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, - onOpenDrawer: () -> Unit, - firstFocused: Boolean, - statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, - platformProvider: (VideoChannelDto) -> String?, + onOpenDrawer: () -> Unit = {}, + focused: Boolean, + onFocused: () -> Unit = {}, + stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformName: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } - var focusedIndex by remember { mutableIntStateOf(-1) } - var clickedIndex by remember { mutableIntStateOf(-1) } + var focusedIndex by remember { mutableStateOf(-1) } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { @@ -345,13 +380,15 @@ private fun ChannelRow( if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } - if (firstFocused) { + if (focused) { LaunchedEffect(channels.isNotEmpty()) { - if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } + if (channels.isNotEmpty()) { + if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() + } } } - androidx.compose.foundation.lazy.LazyRow( + LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -362,8 +399,8 @@ private fun ChannelRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, - stats = statsProvider(channel), - platformName = platformProvider(channel), + stats = stats(channel), + platformName = platformName(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -371,16 +408,16 @@ private fun ChannelRow( } } -/** - * Channel card: banner top (center-cropped), round avatar bottom-left, then - * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. +/** + * Channel card: banner (center-cropped), round avatar bottom-left, channel name, + * "owner - platform" subtitle, then icon stats: videos | subscribers | plays. */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, - stats: net.jeena.jtubetv.data.api.ChannelStats? = null, + stats: ChannelStats? = null, platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { @@ -390,6 +427,8 @@ fun ChannelCard( modifier = modifier .width(rowCardWidth()) .padding(6.dp) + .dpadCenterActivatable(onClick) + .onFocusChanged { cardFocused = it.isFocused } .onPreviewKeyEvent { ke -> if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && @@ -418,14 +457,17 @@ fun ChannelCard( .aspectRatio(16f / 9f) .background(Color(0xFF222222)), ) { + // banner, or the newest video's thumbnail for bannerless channels val banner = channel.banners.maxByOrNull { it.width } - if (banner != null) { + val coverUrl = banner?.let { absoluteBanner(it) } + ?: stats?.latestThumbUrl + if (coverUrl != null) { AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(banner.fileUrl ?: banner.path)) - .size(480) + .data(coverUrl) + .size(480, 270) // same key as prefetch .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, @@ -438,8 +480,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absolute(avatar.fileUrl ?: avatar.path)) - .size(80) + .data(absoluteAvatar(avatar)) + .size(80, 80) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -471,34 +513,42 @@ fun ChannelCard( maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically) { - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, - value = stats?.videos?.toString() ?: "-", - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.Groups, - value = channel.followersCount.toString(), - focused = cardFocused, - ) - StatWithIcon( - icon = androidx.compose.material.icons.Icons.Filled.PlayArrow, - value = stats?.plays?.toString() ?: "-", - focused = cardFocused, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused) + StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused) + StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused) } } } } } +private fun absoluteBanner(banner: Avatar): String = + (banner.fileUrl ?: banner.path).let { + if (it.startsWith("http")) it + else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it + } + @Composable -private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) { +private fun StatWithIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + focused: Boolean, +) { val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { - androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + androidx.tv.material3.Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(13.dp), + ) Text(value, style = MaterialTheme.typography.labelSmall, color = color) } -} +} \ No newline at end of file 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 69dafd1..97aa411 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 @@ -153,6 +153,7 @@ fun HomeRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), ) { val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } @@ -188,6 +189,7 @@ fun HomeRowsScreen( onLeftAtStart = onOpenDrawer, clickedIndex = continueRestore, restoreToken = restoreToken, + upToTopBar = upToTopBar, ) } } @@ -257,6 +259,7 @@ fun FocusScrollRow( onLeftAtStart: () -> Unit, clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click restoreToken: Int = 0, // bumped when playback ends + upToTopBar: (() -> Unit)? = null, // first content row only afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, ) { val rowState = rememberLazyListState() @@ -315,6 +318,7 @@ fun FocusScrollRow( compact = true, progress = progressOf(video.shortUUID), onLeftAtStart = if (index == 0) onLeftAtStart else null, + onUpToTopBar = upToTopBar, onFocused = { focusedIndex = index }, ) afterItem(index, video) 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 7e9927d..b031715 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 @@ -55,6 +55,7 @@ import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.screens.list.VideoListScreen +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable enum class Destination(val title: String) { Home("Home"), @@ -76,11 +77,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi val epoch by viewModel.sessionEpoch.collectAsState() var destination by rememberSaveable { mutableStateOf(Destination.Home) } var channelDetails by rememberSaveable { mutableStateOf(null) } // handle - var playingVideo by rememberSaveable { mutableStateOf(null) } // shortUUID + // NOT saveable: launching the app should never resume a half-watched player session. + var playingVideo by remember { mutableStateOf(null) } // shortUUID var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var drawerOpen by rememberSaveable { mutableStateOf(false) } val context = androidx.compose.ui.platform.LocalContext.current var lastBackAt by rememberSaveable { mutableStateOf(0L) } + val searchFocus = remember { FocusRequester() } // After BACK from the player: bump the restore token so lists re-focus the opened // card and re-fetch positions (UX report BUG-3 + BUG-7). @@ -88,34 +91,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // BACK order: player (own BackHandler) → drawer close → channel drilldown → - // BACK (on content) opens the drawer; a double-BACK with the drawer open exits. - BackHandler(enabled = playingVideo == null && channelDetails == null) { - val now = System.currentTimeMillis() - if (drawerOpen) { + // 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) { + drawerOpen = false + runCatching { searchFocus.requestFocus() } + } + 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() return@BackHandler } lastBackAt = now android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() - } else { - lastBackAt = now - drawerOpen = true } } - BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { - channelDetails = null - } - BackHandler(enabled = drawerOpen && playingVideo == null) { - val now = System.currentTimeMillis() - if (now - lastBackAt < 2500) { - (context as? android.app.Activity)?.finish() - return@BackHandler - } - lastBackAt = now - drawerOpen = false - } Box( modifier = Modifier @@ -142,6 +140,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi TopBar( onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, + searchFocus = searchFocus, ) Box( modifier = Modifier @@ -156,6 +155,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -164,6 +164,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Channels -> ChannelsScreen( onOpenChannel = { handle -> channelDetails = handle }, @@ -177,6 +178,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> SubscriptionRowsScreen( @@ -184,6 +186,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -192,6 +195,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, + upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) } } @@ -236,6 +240,7 @@ private fun Destination.icon(): ImageVector = private fun TopBar( onSearchClick: () -> Unit, onAccountClick: () -> Unit, + searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( @@ -249,6 +254,7 @@ private fun TopBar( icon = Icons.Filled.Search, desc = "Search", onClick = onSearchClick, + focusRequester = searchFocus, ) // "Who is logged in" — account button: avatar circle + display name (SmartTube). val accountFocused = remember { mutableStateOf(false) } @@ -261,6 +267,7 @@ private fun TopBar( else Modifier ) .focusable() + .dpadCenterActivatable(onAccountClick) .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), @@ -317,6 +324,7 @@ private fun RoundTopButton( icon: ImageVector, desc: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Box( @@ -330,7 +338,9 @@ private fun RoundTopButton( 3.dp, Color.White, CircleShape ) else Modifier ) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -364,7 +374,16 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .padding(vertical = 24.dp), + .padding(vertical = 24.dp) + .onPreviewKeyEvent { ke -> + // RIGHT from the drawer closes it; focus returns to the top bar. + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && + ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT + ) { + onDismiss() + true + } else false + }, ) { Text( text = "JTube TV", @@ -401,12 +420,11 @@ private fun MenuDrawer( modifier = Modifier, ) } - // tap/click outside closes; focus LEFT from the drawer also closes + // tap/click outside closes the drawer Box( modifier = Modifier .weight(1f) .fillMaxSize() - .focusable() .pointerInput(Unit) { detectTapGestures { onDismiss() } }, ) } @@ -439,6 +457,7 @@ private fun MenuDrawerItem( } ) .focusable() + .dpadCenterActivatable(onClick) .clickable(onClick = onClick) .padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt index 3f4fc88..a41f989 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SetupScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.StateFlow +import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable /** * Feature 0: ask for instance URL + credentials once. The password is used only for @@ -164,9 +165,6 @@ fun SetupScreen( Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error) } Button( - // Deliberately always focusable: on TV a permanently disabled button - // is unreachable and leaves the user stuck without feedback (UX review). - // Validation happens on press with an inline message instead. onClick = { when { url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> @@ -178,7 +176,20 @@ fun SetupScreen( } }, enabled = !busy.value, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .dpadCenterActivatable( + onClick = { + when { + url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> + localError.value = "All fields are required" + else -> { + localError.value = null + onLogin(url.value, username.value, password.value) + } + } + }, + ), ) { if (busy.value) { CircularProgressIndicator(Modifier.width(20.dp)) diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt index 3a73e01..d6e7eff 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -82,6 +82,7 @@ fun SubscriptionRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: SubscriptionRowsViewModel = viewModel( key = "subsrows$epoch", factory = object : ViewModelProvider.Factory { @@ -157,6 +158,7 @@ fun SubscriptionRowsScreen( progressOf = { null }, onOpenVideo = onOpenVideo, onLeftAtStart = onOpenDrawer, + upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() }, ) { rowIndex, _ -> if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { viewModel.loadMore() diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt new file mode 100644 index 0000000..7088eb4 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt @@ -0,0 +1,29 @@ +package net.jeena.jtubetv.ui.screens.list + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.onPreviewKeyEvent + +/** + * Compose `clickable` handles Enter/Space but NOT the TV's DPAD_CENTER; whether + * CENTER works depends on each device's input translation (works on some emulators, + * not on others — see the API-34 ATD case). This modifier consumes CENTER *first* + * (preview/tunneling) so the click fires exactly once, on every device. + */ +fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed { + onPreviewKeyEvent { ke -> + if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + val code = ke.nativeKeyEvent.keyCode + val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + code == android.view.KeyEvent.KEYCODE_ENTER || + code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER || + code == android.view.KeyEvent.KEYCODE_SPACE + if (activates) { + onClick() + true // consumed in preview -> the platform/card path never double-fires + } else false + } else false + } +} \ No newline at end of file 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 4adffc4..5d4e1a9 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 @@ -59,6 +59,8 @@ fun VideoCard( compact: Boolean = false, /** LEFT (only on the row's first card) can open the menu drawer. */ onLeftAtStart: (() -> Unit)? = null, + /** Explicit UP-target for the first content row (focus search misses the top bar). */ + onUpToTopBar: (() -> Unit)? = null, /** Notifies the row wrapper when this card gains focus (for eased scrolling). */ onFocused: (() -> Unit)? = null, ) { @@ -67,17 +69,23 @@ fun VideoCard( onClick = onClick, modifier = modifier .padding(6.dp) + .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused if (it.isFocused) onFocused?.invoke() } .onPreviewKeyEvent { ke -> - if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && - ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && - onLeftAtStart != null - ) { - onLeftAtStart() - true + val direction = ke.key + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) { + when { + direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> { + onLeftAtStart(); true + } + direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> { + onUpToTopBar(); true + } + else -> false + } } else false }, colors = CardDefaults.colors( 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 1cfe009..02e43b3 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 @@ -33,6 +33,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import net.jeena.jtubetv.R import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.ui.list.PagedListViewModel +import net.jeena.jtubetv.ui.screens.rowCardWidth import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.VideoListItem import net.jeena.jtubetv.ui.list.VideoListViewModel @@ -44,6 +45,7 @@ fun VideoListScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, + upToTopBar: (() -> Unit)? = null, viewModel: VideoListViewModel = viewModel( key = "v$epoch:" + sourceKey(source), factory = VideoListViewModel.factory(source), @@ -120,7 +122,8 @@ fun VideoListScreen( } LazyVerticalGrid( - columns = GridCells.Fixed(3), + // 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), ) { @@ -136,7 +139,9 @@ fun VideoListScreen( video = video, onClick = { lastOpenedIndex = index; onOpenVideo(video) }, showPrivacy = source is ListSource.MyVideos, + compact = true, onLeftAtStart = if (index == 0) onOpenDrawer else null, + onUpToTopBar = upToTopBar?.takeIf { index < 4 }, modifier = Modifier .focusRequester(fr), ) diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml index 29ba394..5d78e70 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -7,16 +7,43 @@ - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_foreground.xml b/app/src/main/res/drawable/icon_foreground.xml index 74dc88e..28febb9 100644 --- a/app/src/main/res/drawable/icon_foreground.xml +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -5,13 +5,42 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:scaleX="0.6" + android:scaleY="0.6" + android:translateX="27.5" + android:translateY="26.5"> + android:fillColor="#F2690D" + android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" /> + + android:pathData="M15.0279 42.3829 c 14.4103 -2.9379 34.4644 -4.7509 48.0973 -4.21 5.1043 0.2025 9.3683 4.8586 9.1618 9.7687 -0.2835 6.7398 -1.0404 18.8371 -1.477 25.8761 -0.3086 4.9748 -4.3214 9.3362 -8.8365 9.4034 -14.5318 0.2161 -28.9546 -0.025 -43.8451 0.6671 -4.5318 0.2106 -8.7268 -3.2379 -9.2264 -7.5956 C 7.993 68.3645 6.7849 60.7535 5.9079 52.9211 c -0.4974 -4.4414 3.7431 -9.442 9.1201 -10.5382 z" /> + + + + + + + + \ No newline at end of file diff --git a/scripts/emulator-seed.sh b/scripts/emulator-seed.sh new file mode 100755 index 0000000..7a4bd91 --- /dev/null +++ b/scripts/emulator-seed.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# emulator-seed.sh — (container-side) log the freshly booted emulated device into the +# JTube test account via the debug-only seed intent. Usage: +# ./emulator-seed.sh emulator-5568 [serial] +set -euo pipefail +SERIAL="${1:-emulator-5568}" +ADB="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb" +[ -x "$ADB" ] && ADB="$ADB" || ADB="$(which adb)" + +# credentials from the encrypted store +eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')" + +# kill + cold start so onCreate (the debug seeder) actually runs +"$ADB" -s "$SERIAL" shell am force-stop net.jeena.jtubetv 2>/dev/null || true +"$ADB" -s "$SERIAL" install -r "$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" 2>&1 | tail -1 + +"$ADB" -s "$SERIAL" shell am start -a android.intent.action.MAIN \ + -n net.jeena.jtubetv/.MainActivity \ + --es jtube_url "$JTUBE_URL" \ + --es jtube_user "$JTUBE_USER" \ + --es jtube_pass "$JTUBE_PASS" + +sleep 12 +"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 +"$ADB" -s "$SERIAL" shell cat /sdcard/ui.xml | python3 -c " +import sys, re +xml = sys.stdin.read() +texts = [m.group(1) for m in re.finditer(r'text=\"([^\"]{2,40})\"', xml)] +foc = re.search(r']*focused=\"true\"[^>]*>', xml) +print('logged in as:', texts[0] if texts else '?') +print('rows/screens:', texts[1:8])" \ No newline at end of file