diff --git a/.gitignore b/.gitignore index 8685299..f89a0ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,4 @@ /app/build /captures .externalNativeBuild -.cxx -reports/*-shots/ -reports/*.md +.cxxreports/*-shots/ diff --git a/AGENTS.md b/AGENTS.md index bc283be..1e68716 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,10 +69,9 @@ 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). - - 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.) + - 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. ## Conventions diff --git a/BACKLOG.md b/BACKLOG.md index 5ac444d..08cad82 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,14 +38,12 @@ 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 — 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.) +- 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. - 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 8922c39..5079c0d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -125,20 +125,14 @@ 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 - latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path -> - if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/') - } - } + if (total == null) total = page.total plays += page.data.sumOf { it.views } start += page.data.size videos = total ?: videos } - net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb) + net.jeena.jtubetv.data.api.ChannelStats(videos, plays) } private fun unauthenticatedApiFor(base: String): PeerTubeApi = @@ -184,9 +178,6 @@ 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 91d3777..b807ebc 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,8 +158,6 @@ 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 1c5940a..4c505ce 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,11 +83,6 @@ 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 a723e8e..33bf4aa 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,13 +1,8 @@ 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 @@ -35,12 +30,6 @@ 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 { @@ -71,15 +60,6 @@ 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 a97a119..707db29 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,9 +1,14 @@ 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 @@ -14,20 +19,13 @@ 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 @@ -35,64 +33,65 @@ 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.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.focus.focusRequester +import androidx.compose.ui.graphics.Color 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 -import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable + +/** 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 +} /** - * 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. + * 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. */ -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 - var userRows by mutableStateOf>(emptyList()) + /** 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()) private set var loading by mutableStateOf(true) @@ -100,72 +99,87 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { var error by mutableStateOf(null) private set - /** "name@host" -> stats; filled progressively in the background. */ - var stats by mutableStateOf>(emptyMap()) + /** "name@host" -> videos + plays; filled 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 && userRows.isNotEmpty()) return + if (!force && userChannels.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() - if (page == null) { - retries++ - if (retries > 2) break - delay(500) - continue - } - retries = 0 + val page = repo.channelList(start, 100).getOrNull() ?: break 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) - // 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) + 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() } } catch (e: Exception) { error = e.message ?: "Could not load channels" } finally { @@ -174,84 +188,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() { } } - /** 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 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 } - 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 { @@ -271,12 +212,11 @@ fun ChannelsScreen( ) { LaunchedEffect(epoch) { viewModel.load(force = true) } - when { - viewModel.loading && viewModel.userRows.isEmpty() -> Box( + viewModel.loading && viewModel.localChannels.isEmpty() -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { androidx.compose.material3.CircularProgressIndicator() } - viewModel.error != null && viewModel.userRows.isEmpty() -> + viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Could not load channels", color = MaterialTheme.colorScheme.error) @@ -291,28 +231,60 @@ fun ChannelsScreen( } } else -> { - // 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) { + 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 = group.avatar, - label = group.label, + avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, + label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels", ) } - item(key = "row-" + group.ownerName) { + item { ChannelRow( - group.channels, + viewModel.userChannels, + onOpenChannel, + onOpenDrawer, + firstFocused = true, + statsProvider = { viewModel.stats[it.name + "@" + it.host] }, + platformProvider = { viewModel.platformNames[it.host ?: ""] }, + ) + } + } + + viewModel.localUserRows.forEach { group -> + item { + RowHeader( + avatar = group.avatar, + label = (group.label ?: "?") + " (" + group.videos + ")", + ) + } + item { + ChannelRow( + group.rows, onOpenChannel, - onOpenDrawer, - focused = !focusHandedOut.value && - group == viewModel.userRows.firstOrNull(), - onFocused = { focusHandedOut.value = true }, - stats = { viewModel.stats[it.name + "@" + it.host] }, - platformName = { viewModel.platformNames[it.host ?: ""] }, + {}, + 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 ?: ""] }, ) } } @@ -321,9 +293,9 @@ fun ChannelsScreen( } } -/** Row header: user icon + "'s Channels". */ +/** Row header: user icon + username, or plain label. */ @Composable -private fun RowHeader(avatar: Avatar?, label: String) { +private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), @@ -331,12 +303,7 @@ private fun RowHeader(avatar: Avatar?, label: String) { ) { if (avatar != null) { AsyncImage( - model = coil.request.ImageRequest.Builder( - androidx.compose.ui.platform.LocalContext.current, - ) - .data(absoluteAvatar(avatar)) - .size(80) - .build(), + model = absolute(avatar.fileUrl ?: avatar.path), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -349,26 +316,24 @@ private fun RowHeader(avatar: Avatar?, label: String) { } } -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 - } +private fun absolute(pathOrUrl: String): String = + if (pathOrUrl.startsWith("http")) pathOrUrl + else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl -/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */ +/** Horizontal row of channel cards; eases newly focused cards into view. */ @Composable private fun ChannelRow( channels: List, onOpenChannel: (String) -> Unit, - onOpenDrawer: () -> Unit = {}, - focused: Boolean, - onFocused: () -> Unit = {}, - stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, - platformName: (VideoChannelDto) -> String?, + onOpenDrawer: () -> Unit, + firstFocused: Boolean, + statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, + platformProvider: (VideoChannelDto) -> String?, ) { val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val focusRequesters = remember { HashMap() } - var focusedIndex by remember { mutableStateOf(-1) } + var focusedIndex by remember { mutableIntStateOf(-1) } + var clickedIndex by remember { mutableIntStateOf(-1) } LaunchedEffect(focusedIndex) { if (focusedIndex > 0) { @@ -380,15 +345,13 @@ private fun ChannelRow( if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) } } - if (focused) { + if (firstFocused) { LaunchedEffect(channels.isNotEmpty()) { - if (channels.isNotEmpty()) { - if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused() - } + if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } } } - LazyRow( + androidx.compose.foundation.lazy.LazyRow( state = rowState, contentPadding = PaddingValues(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -399,8 +362,8 @@ private fun ChannelRow( ChannelCard( channel = channel, onClick = { onOpenChannel("${channel.name}@${channel.host}") }, - stats = stats(channel), - platformName = platformName(channel), + stats = statsProvider(channel), + platformName = platformProvider(channel), onLeftAtStart = if (index == 0) onOpenDrawer else null, modifier = Modifier.focusRequester(fr), ) @@ -408,16 +371,16 @@ private fun ChannelRow( } } -/** - * Channel card: banner (center-cropped), round avatar bottom-left, channel name, - * "owner - platform" subtitle, then icon stats: videos | subscribers | plays. +/** + * Channel card: banner top (center-cropped), round avatar bottom-left, then + * channel name, "ownerDisplayName - platformDisplayName", stat line with icons. */ @Composable fun ChannelCard( channel: VideoChannelDto, onClick: () -> Unit, modifier: Modifier = Modifier, - stats: ChannelStats? = null, + stats: net.jeena.jtubetv.data.api.ChannelStats? = null, platformName: String? = null, onLeftAtStart: (() -> Unit)? = null, ) { @@ -427,8 +390,6 @@ 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 && @@ -457,17 +418,14 @@ 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 } - val coverUrl = banner?.let { absoluteBanner(it) } - ?: stats?.latestThumbUrl - if (coverUrl != null) { + if (banner != null) { AsyncImage( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(coverUrl) - .size(480, 270) // same key as prefetch + .data(absolute(banner.fileUrl ?: banner.path)) + .size(480) .build(), contentDescription = channel.displayName, contentScale = ContentScale.Crop, @@ -480,8 +438,8 @@ fun ChannelCard( model = coil.request.ImageRequest.Builder( androidx.compose.ui.platform.LocalContext.current, ) - .data(absoluteAvatar(avatar)) - .size(80, 80) + .data(absolute(avatar.fileUrl ?: avatar.path)) + .size(80) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -513,42 +471,34 @@ fun ChannelCard( maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) - 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) + 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, + ) } } } } } -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 97aa411..69dafd1 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,7 +153,6 @@ 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) } @@ -189,7 +188,6 @@ fun HomeRowsScreen( onLeftAtStart = onOpenDrawer, clickedIndex = continueRestore, restoreToken = restoreToken, - upToTopBar = upToTopBar, ) } } @@ -259,7 +257,6 @@ 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() @@ -318,7 +315,6 @@ 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 b031715..7e9927d 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,7 +55,6 @@ 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"), @@ -77,13 +76,11 @@ 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 - // NOT saveable: launching the app should never resume a half-watched player session. - var playingVideo by remember { mutableStateOf(null) } // shortUUID + var playingVideo by rememberSaveable { 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). @@ -91,29 +88,34 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi if (playingVideo == null && restoreToken >= 1) restoreToken++ } - // 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() + // 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) { 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 @@ -140,7 +142,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi TopBar( onSearchClick = { destination = Destination.Search; channelDetails = null }, onAccountClick = { drawerOpen = !drawerOpen }, - searchFocus = searchFocus, ) Box( modifier = Modifier @@ -155,7 +156,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.MyVideos -> VideoListScreen( @@ -164,7 +164,6 @@ 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 }, @@ -178,7 +177,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.Subscriptions -> SubscriptionRowsScreen( @@ -186,7 +184,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) Destination.History -> VideoListScreen( @@ -195,7 +192,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi restoreToken = restoreToken, epoch = epoch, onOpenDrawer = { drawerOpen = true }, - upToTopBar = { runCatching { searchFocus.requestFocus() } }, ) } } @@ -240,7 +236,6 @@ private fun Destination.icon(): ImageVector = private fun TopBar( onSearchClick: () -> Unit, onAccountClick: () -> Unit, - searchFocus: FocusRequester, modifier: Modifier = Modifier, ) { Row( @@ -254,7 +249,6 @@ 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) } @@ -267,7 +261,6 @@ private fun TopBar( else Modifier ) .focusable() - .dpadCenterActivatable(onAccountClick) .clickable(onClick = onAccountClick), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), @@ -324,7 +317,6 @@ private fun RoundTopButton( icon: ImageVector, desc: String, onClick: () -> Unit, - focusRequester: FocusRequester? = null, ) { var focused by remember { mutableStateOf(false) } Box( @@ -338,9 +330,7 @@ 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, ) { @@ -374,16 +364,7 @@ private fun MenuDrawer( .width(240.dp) .fillMaxSize() .background(Color(0xFF111111)) - .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 - }, + .padding(vertical = 24.dp), ) { Text( text = "JTube TV", @@ -420,11 +401,12 @@ private fun MenuDrawer( modifier = Modifier, ) } - // tap/click outside closes the drawer + // tap/click outside closes; focus LEFT from the drawer also closes Box( modifier = Modifier .weight(1f) .fillMaxSize() + .focusable() .pointerInput(Unit) { detectTapGestures { onDismiss() } }, ) } @@ -457,7 +439,6 @@ 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 a41f989..3f4fc88 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,7 +28,6 @@ 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 @@ -165,6 +164,9 @@ 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() -> @@ -176,20 +178,7 @@ fun SetupScreen( } }, enabled = !busy.value, - 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) - } - } - }, - ), + modifier = Modifier.fillMaxWidth(), ) { 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 d6e7eff..3a73e01 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,7 +82,6 @@ fun SubscriptionRowsScreen( restoreToken: Int = 0, epoch: Int = 0, onOpenDrawer: () -> Unit = {}, - upToTopBar: (() -> Unit)? = null, viewModel: SubscriptionRowsViewModel = viewModel( key = "subsrows$epoch", factory = object : ViewModelProvider.Factory { @@ -158,7 +157,6 @@ 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 deleted file mode 100644 index 7088eb4..0000000 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/list/FocusKeys.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 5d4e1a9..4adffc4 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,8 +59,6 @@ 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, ) { @@ -69,23 +67,17 @@ fun VideoCard( onClick = onClick, modifier = modifier .padding(6.dp) - .dpadCenterActivatable(onClick) .onFocusChanged { cardFocused = it.isFocused if (it.isFocused) onFocused?.invoke() } .onPreviewKeyEvent { ke -> - 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 - } + if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && + ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && + onLeftAtStart != null + ) { + onLeftAtStart() + true } 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 02e43b3..1cfe009 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,7 +33,6 @@ 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 @@ -45,7 +44,6 @@ 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), @@ -122,8 +120,7 @@ fun VideoListScreen( } LazyVerticalGrid( - // Same card size as the Home rows: ~4 fit per row (user request). - columns = GridCells.Adaptive(rowCardWidth() - 12.dp), + columns = GridCells.Fixed(3), state = gridState, modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), ) { @@ -139,9 +136,7 @@ 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 5d78e70..29ba394 100644 --- a/app/src/main/res/drawable/app_banner.xml +++ b/app/src/main/res/drawable/app_banner.xml @@ -7,43 +7,16 @@ - - - - - - - - - - - - - + + + + \ 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 28febb9..74dc88e 100644 --- a/app/src/main/res/drawable/icon_foreground.xml +++ b/app/src/main/res/drawable/icon_foreground.xml @@ -5,42 +5,13 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:translateX="30" + android:translateY="30"> - + android:fillColor="#CE2B2B" + android:pathData="M4,4h40a4,4 0 0 1 4,4v40a4,4 0 0 1 -4,4h-40a4,4 0 0 1 -4,-4v-40a4,4 0 0 1 4,-4z" /> - - - - - - - - + android:pathData="M20,21l13,10l-13,10z" /> \ No newline at end of file diff --git a/scripts/emulator-seed.sh b/scripts/emulator-seed.sh deleted file mode 100755 index 7a4bd91..0000000 --- a/scripts/emulator-seed.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/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