Compare commits

..

No commits in common. "73b1c86790f761a19a67a26d4b98fbb3f363cbeb" and "d74a71758b9a448d31da0723e1dc4ecf888db866" have entirely different histories.

18 changed files with 256 additions and 513 deletions

4
.gitignore vendored
View file

@ -7,6 +7,4 @@
/app/build /app/build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxxreports/*-shots/
reports/*-shots/
reports/*.md

View file

@ -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 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 `POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs
(response shape: `{ files: { token, expires } }`; verified live). (response shape: `{ files: { token, expires } }`; verified live).
- Some instances stream non-public media from object-storage hosts with only - tube.jeena.net itself streams non-public media from a **publicly readable R2
URL-based access control — never rely on that; always request and use the bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on
file token for non-public videos. (Instance-specific findings stay out of that — always request the file token for non-public videos.
the public repo and live in the private ops notes/agent-local instructions.)
## Conventions ## Conventions

View file

@ -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 - Logout / switch account: sign-out entry exists; add switch-account (re-login screen
without losing scroll state). without losing scroll state).
- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning. - Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning.
- Thumbnails of private/internal videos may 404 — on some instances thumbnail - Thumbnails of private/internal videos may 404 — verified on tube.jeena.net:
paths are publicly readable while on others they need auth; add a fallback instance thumbnail paths are publicly readable (HTTP 200 without auth); add a
poster only if needed. fallback poster only if other instances behave differently.
- Security note (verified live against tube.jeena.net): some instances serve - Security note for tube.jeena.net: object-storage (R2) media for private test
non-public media / thumbnails from object storage with only URL-based access videos is publicly readable without token ("private" only via URL obscurity).
control. The app must not rely on that laxness; always send auth headers Flag to the instance admin; the app must not rely on this laxness.
/ 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 - Verify `searchTarget=local` channel search behaves well for instances without
federation; consider `search/video-channels` endpoint as an alternative. federation; consider `search/video-channels` endpoint as an alternative.
- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme. - Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.

View file

@ -125,20 +125,14 @@ class PeerTubeRepository(
var videos = 0L var videos = 0L
var plays = 0L var plays = 0L
var total: Long? = null var total: Long? = null
var latestThumb: String? = null
repeat(maxPages) { repeat(maxPages) {
val page = network.api.channelVideos(handle, start, 25) val page = network.api.channelVideos(handle, start, 25)
if (total == null) { if (total == null) total = page.total
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 } plays += page.data.sumOf { it.views }
start += page.data.size start += page.data.size
videos = total ?: videos 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 = private fun unauthenticatedApiFor(base: String): PeerTubeApi =
@ -184,9 +178,6 @@ class PeerTubeRepository(
response.data to response.total response.data to response.total
} }
suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
runCatching { network.api.videoChannel(handle) }
suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> = suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
runCatching { runCatching {
val response: VideoChannelListResponse = network.api.accountChannels(username) val response: VideoChannelListResponse = network.api.accountChannels(username)

View file

@ -158,8 +158,6 @@ data class InstanceInfoDto(
data class ChannelStats( data class ChannelStats(
val videos: Long, val videos: Long,
val plays: Long, val plays: Long,
/** Thumbnail of the newest video — banner fallback for channels without one. */
val latestThumbUrl: String? = null,
) )
@Serializable @Serializable

View file

@ -83,11 +83,6 @@ interface PeerTubeApi {
@Path("account") account: String, @Path("account") account: String,
): VideoChannelListResponse ): VideoChannelListResponse
@GET("video-channels/{handle}")
suspend fun videoChannel(
@Path("handle") handle: String,
): VideoChannelDto
@GET("video-channels/{handle}/videos") @GET("video-channels/{handle}/videos")
suspend fun channelVideos( suspend fun channelVideos(
@Path("handle") handle: String, @Path("handle") handle: String,

View file

@ -1,13 +1,8 @@
package net.jeena.jtubetv.ui.list 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.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
@ -35,12 +30,6 @@ class VideoListViewModel(
val query = MutableStateFlow("") val query = MutableStateFlow("")
private var activeSearch: String? = null private var activeSearch: String? = null
/** For channel pages: the channel itself (banner + display name) as page header. */
var channelHeader by androidx.compose.runtime.mutableStateOf<net.jeena.jtubetv.data.api.VideoChannelDto?>(
null,
)
private set
override fun onChunkLoaded(chunk: List<VideoListItem>) { override fun onChunkLoaded(chunk: List<VideoListItem>) {
ImagePrefetcher.videoThumbnails( ImagePrefetcher.videoThumbnails(
chunk.mapNotNull { 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) { fun triggerSearch(raw: String) {
query.value = raw query.value = raw
activeSearch = raw.trim().ifBlank { null } activeSearch = raw.trim().ifBlank { null }

View file

@ -1,9 +1,14 @@
package net.jeena.jtubetv.ui.screens package net.jeena.jtubetv.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -35,64 +33,65 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester 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.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.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.BorderStroke
import androidx.tv.material3.Border import androidx.tv.material3.Border
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import coil.compose.AsyncImage import coil.compose.AsyncImage
import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.jeena.jtubetv.AppGraph import net.jeena.jtubetv.AppGraph
import net.jeena.jtubetv.data.PeerTubeRepository import net.jeena.jtubetv.data.PeerTubeRepository
import net.jeena.jtubetv.data.auth.TokenStore import net.jeena.jtubetv.data.auth.TokenStore
import net.jeena.jtubetv.data.api.Avatar 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.UserDto
import net.jeena.jtubetv.data.api.VideoChannelDto 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.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg 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<VideoChannelDto>,
) {
/** 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. * Channels as SmartTube-style rows:
* * 1. "Your channels" — header with account avatar + username, then own channel cards.
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local * 2. "Local channels" — the instance's own channels. The public API has NO
* users sorted by videos / subscribers / plays (desc). * local-only filter (verified in v8.3.1: both /accounts and /video-channels
* - Each row: that user's channels, same ordering; channels with no videos simply * listings ignore local filters), so we page the whole /video-channels list
* stay inside their user's row. * (max 100/page) and split by host client-side.
* - Followed-instance channels at the end, grouped per platform. * 3. "Followed instances" — channels of instances this server follows.
*
* 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<VideoChannelDto>,
)
class ChannelsViewModel(private val store: TokenStore) : ViewModel() { class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var user by mutableStateOf<UserDto?>(null) var user by mutableStateOf<UserDto?>(null)
private set private set
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList()) var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set private set
var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set
var userRows by mutableStateOf<List<UserGroup>>(emptyList()) /** One row per (local) user, sorted by total video count; empties last. */
var localUserRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
/** Channels from instances this server follows, grouped by their instance. */
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
private set private set
var loading by mutableStateOf(true) var loading by mutableStateOf(true)
@ -100,72 +99,87 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
var error by mutableStateOf<String?>(null) var error by mutableStateOf<String?>(null)
private set private set
/** "name@host" -> stats; filled progressively in the background. */ /** "name@host" -> videos + plays; filled in the background. */
var stats by mutableStateOf<Map<String, ChannelStats>>(emptyMap()) var stats by mutableStateOf<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(emptyMap())
private set private set
/** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */
var platformNames by mutableStateOf<Map<String, String>>(emptyMap()) var platformNames by mutableStateOf<Map<String, String>>(emptyMap())
private set private set
private var allChannels: List<VideoChannelDto> = emptyList()
/** Channels from followed instances, grouped per platform. */
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
private set
private val repo: PeerTubeRepository = AppGraph.repository private val repo: PeerTubeRepository = AppGraph.repository
private val statsMap = HashMap<String, ChannelStats>()
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
if (!force && userRows.isNotEmpty()) return if (!force && userChannels.isNotEmpty()) return
loading = true loading = true
error = null error = null
viewModelScope.launch { viewModelScope.launch {
try { try {
// The user's own row renders FIRST (fast: one call), the rest streams in.
user = repo.me().getOrNull() user = repo.me().getOrNull()
userChannels = user?.let { u -> userChannels = user?.let { u ->
repo.accountChannels(u.username).getOrNull() ?: emptyList() repo.accountChannels(u.username).getOrNull() ?: emptyList()
} ?: 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<VideoChannelDto>() val all = mutableListOf<VideoChannelDto>()
var start = 0L var start = 0L
var total: Long? = null var total: Long? = null
var retries = 0
while (total == null || start < total) { while (total == null || start < total) {
val page = repo.channelList(start, 100).getOrNull() val page = repo.channelList(start, 100).getOrNull() ?: break
if (page == null) {
retries++
if (retries > 2) break
delay(500)
continue
}
retries = 0
if (total == null) total = page.second if (total == null) total = page.second
val known = all.map { it.name to it.host }.toSet() val known = all.map { it.name to it.host }.toSet()
all += page.first.filter { c -> (c.name to c.host) !in known } all += page.first.filter { c -> (c.name to c.host) !in known }
start += 100 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 host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
val locals = all.filter { it.host == host } val ownNames = userChannels.map { c -> c.name }.toSet()
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size + val mine = all.filter { it.host == host && it.name in ownNames }
" userChannels=" + userChannels.size) val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } }
channelStatsLoop(locals, 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) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {
@ -174,84 +188,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
} }
} }
/** Fetch stats for [channels], re-sorting rows as data lands. */ private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—"
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) { private fun rowLabel(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.displayName
viewModelScope.launch { private fun rowAvatar(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width }
for (channel in channels) { private fun rowTotal(row: List<VideoChannelDto>) = row.sumOf { it.videosCount }
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<VideoChannelDto> { 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<VideoChannelDto> { 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<UserGroup> { 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 { companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@ -271,12 +212,11 @@ fun ChannelsScreen(
) { ) {
LaunchedEffect(epoch) { viewModel.load(force = true) } LaunchedEffect(epoch) { viewModel.load(force = true) }
when { when {
viewModel.loading && viewModel.userRows.isEmpty() -> Box( viewModel.loading && viewModel.localChannels.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center, Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
) { androidx.compose.material3.CircularProgressIndicator() } ) { 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) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Could not load channels", color = MaterialTheme.colorScheme.error) Text("Could not load channels", color = MaterialTheme.colorScheme.error)
@ -291,28 +231,60 @@ fun ChannelsScreen(
} }
} }
else -> { else -> {
// hand the initial focus to the first row exactly ONCE per screen visit: val lastOpened = remember { mutableIntStateOf(-1) }
// as stats stream in the rows re-sort, and re-focusing their first card var focusedRow by remember { mutableIntStateOf(0) }
// would steal focus mid-navigation (user: "up jumps to the first row").
val focusHandedOut = remember { mutableStateOf(false) } androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize()) { if (viewModel.userChannels.isNotEmpty()) {
viewModel.userRows.forEach { group -> item {
item(key = "hdr-" + group.ownerName) {
RowHeader( RowHeader(
avatar = group.avatar, avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
label = group.label, label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels",
) )
} }
item(key = "row-" + group.ownerName) { item {
ChannelRow( 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, onOpenChannel,
onOpenDrawer, {},
focused = !focusHandedOut.value && firstFocused = false,
group == viewModel.userRows.firstOrNull(), statsProvider = { viewModel.stats[it.name + "@" + it.host] },
onFocused = { focusHandedOut.value = true }, platformProvider = { viewModel.platformNames[it.host ?: ""] },
stats = { viewModel.stats[it.name + "@" + it.host] }, )
platformName = { 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 + "<name>'s Channels". */ /** Row header: user icon + username, or plain label. */
@Composable @Composable
private fun RowHeader(avatar: Avatar?, label: String) { private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), 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) { if (avatar != null) {
AsyncImage( AsyncImage(
model = coil.request.ImageRequest.Builder( model = absolute(avatar.fileUrl ?: avatar.path),
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteAvatar(avatar))
.size(80)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@ -349,26 +316,24 @@ private fun RowHeader(avatar: Avatar?, label: String) {
} }
} }
private fun absoluteAvatar(avatar: Avatar): String = private fun absolute(pathOrUrl: String): String =
(avatar.fileUrl ?: avatar.path).let { if (pathOrUrl.startsWith("http")) pathOrUrl
if (it.startsWith("http")) it else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
}
/** 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 @Composable
private fun ChannelRow( private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit,
focused: Boolean, firstFocused: Boolean,
onFocused: () -> Unit = {}, statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, platformProvider: (VideoChannelDto) -> String?,
platformName: (VideoChannelDto) -> String?,
) { ) {
val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() } val focusRequesters = remember { HashMap<Int, FocusRequester>() }
var focusedIndex by remember { mutableStateOf(-1) } var focusedIndex by remember { mutableIntStateOf(-1) }
var clickedIndex by remember { mutableIntStateOf(-1) }
LaunchedEffect(focusedIndex) { LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) { if (focusedIndex > 0) {
@ -380,15 +345,13 @@ private fun ChannelRow(
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
} }
} }
if (focused) { if (firstFocused) {
LaunchedEffect(channels.isNotEmpty()) { LaunchedEffect(channels.isNotEmpty()) {
if (channels.isNotEmpty()) { if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() }
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
}
} }
} }
LazyRow( androidx.compose.foundation.lazy.LazyRow(
state = rowState, state = rowState,
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -399,8 +362,8 @@ private fun ChannelRow(
ChannelCard( ChannelCard(
channel = channel, channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onClick = { onOpenChannel("${channel.name}@${channel.host}") },
stats = stats(channel), stats = statsProvider(channel),
platformName = platformName(channel), platformName = platformProvider(channel),
onLeftAtStart = if (index == 0) onOpenDrawer else null, onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier.focusRequester(fr), modifier = Modifier.focusRequester(fr),
) )
@ -408,16 +371,16 @@ private fun ChannelRow(
} }
} }
/** /**
* Channel card: banner (center-cropped), round avatar bottom-left, channel name, * Channel card: banner top (center-cropped), round avatar bottom-left, then
* "owner - platform" subtitle, then icon stats: videos | subscribers | plays. * channel name, "ownerDisplayName - platformDisplayName", stat line with icons.
*/ */
@Composable @Composable
fun ChannelCard( fun ChannelCard(
channel: VideoChannelDto, channel: VideoChannelDto,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
stats: ChannelStats? = null, stats: net.jeena.jtubetv.data.api.ChannelStats? = null,
platformName: String? = null, platformName: String? = null,
onLeftAtStart: (() -> Unit)? = null, onLeftAtStart: (() -> Unit)? = null,
) { ) {
@ -427,8 +390,6 @@ fun ChannelCard(
modifier = modifier modifier = modifier
.width(rowCardWidth()) .width(rowCardWidth())
.padding(6.dp) .padding(6.dp)
.dpadCenterActivatable(onClick)
.onFocusChanged { cardFocused = it.isFocused }
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN && if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT &&
@ -457,17 +418,14 @@ fun ChannelCard(
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.background(Color(0xFF222222)), .background(Color(0xFF222222)),
) { ) {
// banner, or the newest video's thumbnail for bannerless channels
val banner = channel.banners.maxByOrNull { it.width } val banner = channel.banners.maxByOrNull { it.width }
val coverUrl = banner?.let { absoluteBanner(it) } if (banner != null) {
?: stats?.latestThumbUrl
if (coverUrl != null) {
AsyncImage( AsyncImage(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
.data(coverUrl) .data(absolute(banner.fileUrl ?: banner.path))
.size(480, 270) // same key as prefetch .size(480)
.build(), .build(),
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -480,8 +438,8 @@ fun ChannelCard(
model = coil.request.ImageRequest.Builder( model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current, androidx.compose.ui.platform.LocalContext.current,
) )
.data(absoluteAvatar(avatar)) .data(absolute(avatar.fileUrl ?: avatar.path))
.size(80, 80) .size(80)
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -513,42 +471,34 @@ fun ChannelCard(
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
Row( Row(horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically, StatWithIcon(
) { icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary,
StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused) value = stats?.videos?.toString() ?: "-",
StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused) focused = cardFocused,
StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", 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 @Composable
private fun StatWithIcon( private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) {
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
focused: Boolean,
) {
val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A) val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A)
Row( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
verticalAlignment = Alignment.CenterVertically, androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp))
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) Text(value, style = MaterialTheme.typography.labelSmall, color = color)
} }
} }

View file

@ -153,7 +153,6 @@ fun HomeRowsScreen(
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
upToTopBar: (() -> Unit)? = null,
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory), viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
) { ) {
val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) } val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
@ -189,7 +188,6 @@ fun HomeRowsScreen(
onLeftAtStart = onOpenDrawer, onLeftAtStart = onOpenDrawer,
clickedIndex = continueRestore, clickedIndex = continueRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
upToTopBar = upToTopBar,
) )
} }
} }
@ -259,7 +257,6 @@ fun FocusScrollRow(
onLeftAtStart: () -> Unit, onLeftAtStart: () -> Unit,
clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click
restoreToken: Int = 0, // bumped when playback ends restoreToken: Int = 0, // bumped when playback ends
upToTopBar: (() -> Unit)? = null, // first content row only
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> }, afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
@ -318,7 +315,6 @@ fun FocusScrollRow(
compact = true, compact = true,
progress = progressOf(video.shortUUID), progress = progressOf(video.shortUUID),
onLeftAtStart = if (index == 0) onLeftAtStart else null, onLeftAtStart = if (index == 0) onLeftAtStart else null,
onUpToTopBar = upToTopBar,
onFocused = { focusedIndex = index }, onFocused = { focusedIndex = index },
) )
afterItem(index, video) afterItem(index, video)

View file

@ -55,7 +55,6 @@ import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.AppViewModel import net.jeena.jtubetv.ui.AppViewModel
import net.jeena.jtubetv.ui.list.ListSource import net.jeena.jtubetv.ui.list.ListSource
import net.jeena.jtubetv.ui.screens.list.VideoListScreen import net.jeena.jtubetv.ui.screens.list.VideoListScreen
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
enum class Destination(val title: String) { enum class Destination(val title: String) {
Home("Home"), Home("Home"),
@ -77,13 +76,11 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
val epoch by viewModel.sessionEpoch.collectAsState() val epoch by viewModel.sessionEpoch.collectAsState()
var destination by rememberSaveable { mutableStateOf(Destination.Home) } var destination by rememberSaveable { mutableStateOf(Destination.Home) }
var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle var channelDetails by rememberSaveable { mutableStateOf<String?>(null) } // handle
// NOT saveable: launching the app should never resume a half-watched player session. var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID
var playingVideo by remember { mutableStateOf<String?>(null) } // shortUUID
var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends var restoreToken by rememberSaveable { mutableStateOf(0) } // increments when playback ends
var drawerOpen by rememberSaveable { mutableStateOf(false) } var drawerOpen by rememberSaveable { mutableStateOf(false) }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var lastBackAt by rememberSaveable { mutableStateOf(0L) } 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 // 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). // 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++ if (playingVideo == null && restoreToken >= 1) restoreToken++
} }
// BACK order (TV standard): player (own handler) → drawer close → channel // BACK order: player (own BackHandler) → drawer close → channel drilldown →
// drilldown → non-Home destination → Home; from Home: double-press exits. // BACK (on content) opens the drawer; a double-BACK with the drawer open exits.
BackHandler(enabled = drawerOpen && playingVideo == null) { BackHandler(enabled = playingVideo == null && channelDetails == null) {
drawerOpen = false val now = System.currentTimeMillis()
runCatching { searchFocus.requestFocus() } if (drawerOpen) {
}
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) { if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish() (context as? android.app.Activity)?.finish()
return@BackHandler return@BackHandler
} }
lastBackAt = now lastBackAt = now
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() 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( Box(
modifier = Modifier modifier = Modifier
@ -140,7 +142,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
TopBar( TopBar(
onSearchClick = { destination = Destination.Search; channelDetails = null }, onSearchClick = { destination = Destination.Search; channelDetails = null },
onAccountClick = { drawerOpen = !drawerOpen }, onAccountClick = { drawerOpen = !drawerOpen },
searchFocus = searchFocus,
) )
Box( Box(
modifier = Modifier modifier = Modifier
@ -155,7 +156,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
) )
Destination.MyVideos -> Destination.MyVideos ->
VideoListScreen( VideoListScreen(
@ -164,7 +164,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
) )
Destination.Channels -> ChannelsScreen( Destination.Channels -> ChannelsScreen(
onOpenChannel = { handle -> channelDetails = handle }, onOpenChannel = { handle -> channelDetails = handle },
@ -178,7 +177,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
) )
Destination.Subscriptions -> Destination.Subscriptions ->
SubscriptionRowsScreen( SubscriptionRowsScreen(
@ -186,7 +184,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
) )
Destination.History -> Destination.History ->
VideoListScreen( VideoListScreen(
@ -195,7 +192,6 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
upToTopBar = { runCatching { searchFocus.requestFocus() } },
) )
} }
} }
@ -240,7 +236,6 @@ private fun Destination.icon(): ImageVector =
private fun TopBar( private fun TopBar(
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onAccountClick: () -> Unit, onAccountClick: () -> Unit,
searchFocus: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -254,7 +249,6 @@ private fun TopBar(
icon = Icons.Filled.Search, icon = Icons.Filled.Search,
desc = "Search", desc = "Search",
onClick = onSearchClick, onClick = onSearchClick,
focusRequester = searchFocus,
) )
// "Who is logged in" — account button: avatar circle + display name (SmartTube). // "Who is logged in" — account button: avatar circle + display name (SmartTube).
val accountFocused = remember { mutableStateOf(false) } val accountFocused = remember { mutableStateOf(false) }
@ -267,7 +261,6 @@ private fun TopBar(
else Modifier else Modifier
) )
.focusable() .focusable()
.dpadCenterActivatable(onAccountClick)
.clickable(onClick = onAccountClick), .clickable(onClick = onAccountClick),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
@ -324,7 +317,6 @@ private fun RoundTopButton(
icon: ImageVector, icon: ImageVector,
desc: String, desc: String,
onClick: () -> Unit, onClick: () -> Unit,
focusRequester: FocusRequester? = null,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
Box( Box(
@ -338,9 +330,7 @@ private fun RoundTopButton(
3.dp, Color.White, CircleShape 3.dp, Color.White, CircleShape
) else Modifier ) else Modifier
) )
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.focusable() .focusable()
.dpadCenterActivatable(onClick)
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -374,16 +364,7 @@ private fun MenuDrawer(
.width(240.dp) .width(240.dp)
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF111111)) .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(
text = "JTube TV", text = "JTube TV",
@ -420,11 +401,12 @@ private fun MenuDrawer(
modifier = Modifier, modifier = Modifier,
) )
} }
// tap/click outside closes the drawer // tap/click outside closes; focus LEFT from the drawer also closes
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .fillMaxSize()
.focusable()
.pointerInput(Unit) { detectTapGestures { onDismiss() } }, .pointerInput(Unit) { detectTapGestures { onDismiss() } },
) )
} }
@ -457,7 +439,6 @@ private fun MenuDrawerItem(
} }
) )
.focusable() .focusable()
.dpadCenterActivatable(onClick)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp), .padding(horizontal = 24.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -28,7 +28,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow 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 * 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) Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error)
} }
Button( 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 = { onClick = {
when { when {
url.value.isBlank() || username.value.isBlank() || password.value.isBlank() -> url.value.isBlank() || username.value.isBlank() || password.value.isBlank() ->
@ -176,20 +178,7 @@ fun SetupScreen(
} }
}, },
enabled = !busy.value, enabled = !busy.value,
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.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) { if (busy.value) {
CircularProgressIndicator(Modifier.width(20.dp)) CircularProgressIndicator(Modifier.width(20.dp))

View file

@ -82,7 +82,6 @@ fun SubscriptionRowsScreen(
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
upToTopBar: (() -> Unit)? = null,
viewModel: SubscriptionRowsViewModel = viewModel( viewModel: SubscriptionRowsViewModel = viewModel(
key = "subsrows$epoch", key = "subsrows$epoch",
factory = object : ViewModelProvider.Factory { factory = object : ViewModelProvider.Factory {
@ -158,7 +157,6 @@ fun SubscriptionRowsScreen(
progressOf = { null }, progressOf = { null },
onOpenVideo = onOpenVideo, onOpenVideo = onOpenVideo,
onLeftAtStart = onOpenDrawer, onLeftAtStart = onOpenDrawer,
upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() },
) { rowIndex, _ -> ) { rowIndex, _ ->
if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) { if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) {
viewModel.loadMore() viewModel.loadMore()

View file

@ -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
}
}

View file

@ -59,8 +59,6 @@ fun VideoCard(
compact: Boolean = false, compact: Boolean = false,
/** LEFT (only on the row's first card) can open the menu drawer. */ /** LEFT (only on the row's first card) can open the menu drawer. */
onLeftAtStart: (() -> Unit)? = null, 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). */ /** Notifies the row wrapper when this card gains focus (for eased scrolling). */
onFocused: (() -> Unit)? = null, onFocused: (() -> Unit)? = null,
) { ) {
@ -69,23 +67,17 @@ fun VideoCard(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.padding(6.dp) .padding(6.dp)
.dpadCenterActivatable(onClick)
.onFocusChanged { .onFocusChanged {
cardFocused = it.isFocused cardFocused = it.isFocused
if (it.isFocused) onFocused?.invoke() if (it.isFocused) onFocused?.invoke()
} }
.onPreviewKeyEvent { ke -> .onPreviewKeyEvent { ke ->
val direction = ke.key if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) { ke.key == androidx.compose.ui.input.key.Key.DirectionLeft &&
when { onLeftAtStart != null
direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> { ) {
onLeftAtStart(); true onLeftAtStart()
} true
direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> {
onUpToTopBar(); true
}
else -> false
}
} else false } else false
}, },
colors = CardDefaults.colors( colors = CardDefaults.colors(

View file

@ -33,7 +33,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import net.jeena.jtubetv.R import net.jeena.jtubetv.R
import net.jeena.jtubetv.data.api.VideoDto import net.jeena.jtubetv.data.api.VideoDto
import net.jeena.jtubetv.ui.list.PagedListViewModel 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.ListSource
import net.jeena.jtubetv.ui.list.VideoListItem import net.jeena.jtubetv.ui.list.VideoListItem
import net.jeena.jtubetv.ui.list.VideoListViewModel import net.jeena.jtubetv.ui.list.VideoListViewModel
@ -45,7 +44,6 @@ fun VideoListScreen(
restoreToken: Int = 0, restoreToken: Int = 0,
epoch: Int = 0, epoch: Int = 0,
onOpenDrawer: () -> Unit = {}, onOpenDrawer: () -> Unit = {},
upToTopBar: (() -> Unit)? = null,
viewModel: VideoListViewModel = viewModel( viewModel: VideoListViewModel = viewModel(
key = "v$epoch:" + sourceKey(source), key = "v$epoch:" + sourceKey(source),
factory = VideoListViewModel.factory(source), factory = VideoListViewModel.factory(source),
@ -122,8 +120,7 @@ fun VideoListScreen(
} }
LazyVerticalGrid( LazyVerticalGrid(
// Same card size as the Home rows: ~4 fit per row (user request). columns = GridCells.Fixed(3),
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) { ) {
@ -139,9 +136,7 @@ fun VideoListScreen(
video = video, video = video,
onClick = { lastOpenedIndex = index; onOpenVideo(video) }, onClick = { lastOpenedIndex = index; onOpenVideo(video) },
showPrivacy = source is ListSource.MyVideos, showPrivacy = source is ListSource.MyVideos,
compact = true,
onLeftAtStart = if (index == 0) onOpenDrawer else null, onLeftAtStart = if (index == 0) onOpenDrawer else null,
onUpToTopBar = upToTopBar?.takeIf { index < 4 },
modifier = Modifier modifier = Modifier
.focusRequester(fr), .focusRequester(fr),
) )

View file

@ -7,43 +7,16 @@
<path <path
android:fillColor="#101010" android:fillColor="#101010"
android:pathData="M0,0h320v180h-320z" /> android:pathData="M0,0h320v180h-320z" />
<group <path
android:scaleX="1.2" android:fillColor="#CE2B2B"
android:scaleY="1.2" android:pathData="M40,40h64a8,8 0 0 1 8,8v64a8,8 0 0 1 -8,8h-64a8,8 0 0 1 -8,-8v-64a8,8 0 0 1 8,-8z" />
android:translateX="106.9" <path
android:translateY="34.9"> android:fillColor="#FFFFFF"
<path android:pathData="M60,62l26,20l-26,20z" />
android:fillColor="#F2690D" <path
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:fillColor="#FFFFFF"
<path android:pathData="M128,76h160v10h-160z" />
android:fillColor="#F2690D" <path
android:pathData="M17.5209 35.292 c 15.3522 -3.0444 37.132 -5.2959 52.8675 -3.6599 5.1194 0.5323 9.9726 3.4778 13.4121 7.6102 3.4501 4.1452 5.162 9.076 4.575 13.53 -1.0211 7.7481 -2.1843 15.0762 -3.1707 22.7607 -0.5566 4.3363 -2.8555 8.1041 -5.9399 10.9748 -3.0835 2.8699 -7.1688 5.018 -11.413 5.1055 -15.7693 0.3252 -31.5349 0.3254 -47.3137 0.0128 -4.2188 -0.0836 -8.0762 -2.0481 -11.0163 -4.6009 -2.9365 -2.5497 -5.3231 -5.9933 -5.9481 -10.0185 -1.1112 -7.1561 -2.4617 -14.1198 -3.4669 -21.3147 -0.5774 -4.1328 1.2199 -8.6697 4.5294 -12.5508 3.2903 -3.8584 7.9109 -6.8627 12.8846 -7.849 z" /> android:fillColor="#66FFFFFF"
<path android:pathData="M128,100h120v8h-120z" />
android:fillColor="#FFFFFF"
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" />
<path
android:fillColor="#21747E"
android:pathData="M14.7033 44.4903 c 13.9881 -2.839 33.2434 -4.5158 46.2783 -4.2075 4.793 0.1134 8.7611 4.3804 8.5942 9.0519 -0.2357 6.5991 -0.822 17.8255 -1.2054 24.8051 -0.264 4.805 -4.0527 9.0322 -8.3523 9.1149 -14.0043 0.2692 -27.932 -0.0544 -42.2991 0.6531 -4.3211 0.2128 -8.3023 -3.058 -8.7719 -7.2184 -0.8526 -7.5531 -1.9989 -14.8099 -2.8643 -22.2368 -0.4906 -4.2105 3.499 -8.9228 8.6204 -9.9622 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M83.3821 54.6336 c -0.1383 1.2653 -1.437 1.9756 -2.8571 1.6554 -1.4217 -0.3206 -2.4853 -1.5718 -2.3821 -2.865 0.103 -1.2911 1.3448 -2.1176 2.816 -1.7732 1.4728 0.3448 2.5617 1.7153 2.4232 2.9828 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M80.711 72.609 c -0.1325 1.2783 -1.3091 2.1438 -2.6063 1.9539 -1.2981 -0.19 -2.2687 -1.3851 -2.1615 -2.7124 0.107 -1.3254 1.2582 -2.2397 2.594 -2.0133 1.3369 0.2265 2.3064 1.4913 2.1737 2.7719 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M82.1189 63.4565 c -0.1418 1.2519 -1.38 2.0155 -2.7341 1.7518 -1.3555 -0.2639 -2.3671 -1.4775 -2.2569 -2.7684 0.1101 -1.2889 1.3099 -2.1371 2.711 -1.8424 1.4024 0.295 2.422 1.6049 2.28 2.8589 z" />
<path
android:fillColor="#F2690D"
android:pathData="M24.3564 13.6769 c 0.3252 2.2586 -1.0941 4.3325 -3.1576 4.7592 -2.0665 0.4274 -3.9271 -0.9504 -4.1822 -3.1604 -0.255 -2.2099 1.1547 -4.4794 3.1241 -4.9311 1.973 -0.4525 3.8904 1.0729 4.2157 3.3323 z" />
<path
android:fillColor="#F2690D"
android:pathData="M65.4155 4.8373 c 0.6897 2.6269 -0.737 4.5575 -2.9291 4.5298 -2.1997 -0.0277 -4.3526 -1.9881 -5.0235 -4.5309 -0.6692 -2.5366 0.3587 -4.7994 2.5379 -4.8357 2.1875 -0.0364 4.7231 2.2031 5.4147 4.8368 z" />
<path
android:fillColor="#F2690D"
android:pathData="M22.4411 15.8196 c 0.4039 -0.5491 1.1199 -0.6467 1.6156 -0.216 5.1717 4.4937 10.1896 9.0068 15.0872 13.6473 0.4692 0.4446 0.5007 1.187 0.0731 1.663 -0.4275 0.476 -1.1483 0.5048 -1.6131 0.0681 -4.8696 -4.5749 -9.8615 -9.0137 -15.0074 -13.4151 -0.4911 -0.42 -0.5594 -1.1982 -0.1554 -1.7474 z" />
<path
android:fillColor="#F2690D"
android:pathData="M60.9631 6.1733 c 0.6295 0.5159 0.8777 1.409 0.5531 1.9942 -3.3779 6.0895 -7.181 11.901 -11.1403 17.4699 -0.3804 0.5349 -1.0986 0.6302 -1.6054 0.2202 -0.5069 -0.41 -0.6158 -1.1754 -0.2423 -1.7185 3.8871 -5.6512 7.5636 -11.5653 10.7496 -17.8077 0.306 -0.5996 1.0558 -0.674 1.6853 -0.1581 z" />
</group>
</vector> </vector>

View file

@ -5,42 +5,13 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <group
android:scaleX="0.6" android:translateX="30"
android:scaleY="0.6" android:translateY="30">
android:translateX="27.5"
android:translateY="26.5">
<path <path
android:fillColor="#F2690D" android:fillColor="#CE2B2B"
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="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" />
<path
android:fillColor="#F2690D"
android:pathData="M17.5209 35.292 c 15.3522 -3.0444 37.132 -5.2959 52.8675 -3.6599 5.1194 0.5323 9.9726 3.4778 13.4121 7.6102 3.4501 4.1452 5.162 9.076 4.575 13.53 -1.0211 7.7481 -2.1843 15.0762 -3.1707 22.7607 -0.5566 4.3363 -2.8555 8.1041 -5.9399 10.9748 -3.0835 2.8699 -7.1688 5.018 -11.413 5.1055 -15.7693 0.3252 -31.5349 0.3254 -47.3137 0.0128 -4.2188 -0.0836 -8.0762 -2.0481 -11.0163 -4.6009 -2.9365 -2.5497 -5.3231 -5.9933 -5.9481 -10.0185 -1.1112 -7.1561 -2.4617 -14.1198 -3.4669 -21.3147 -0.5774 -4.1328 1.2199 -8.6697 4.5294 -12.5508 3.2903 -3.8584 7.9109 -6.8627 12.8846 -7.849 z" />
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
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" /> android:pathData="M20,21l13,10l-13,10z" />
<path
android:fillColor="#21747E"
android:pathData="M14.7033 44.4903 c 13.9881 -2.839 33.2434 -4.5158 46.2783 -4.2075 4.793 0.1134 8.7611 4.3804 8.5942 9.0519 -0.2357 6.5991 -0.822 17.8255 -1.2054 24.8051 -0.264 4.805 -4.0527 9.0322 -8.3523 9.1149 -14.0043 0.2692 -27.932 -0.0544 -42.2991 0.6531 -4.3211 0.2128 -8.3023 -3.058 -8.7719 -7.2184 -0.8526 -7.5531 -1.9989 -14.8099 -2.8643 -22.2368 -0.4906 -4.2105 3.499 -8.9228 8.6204 -9.9622 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M83.3821 54.6336 c -0.1383 1.2653 -1.437 1.9756 -2.8571 1.6554 -1.4217 -0.3206 -2.4853 -1.5718 -2.3821 -2.865 0.103 -1.2911 1.3448 -2.1176 2.816 -1.7732 1.4728 0.3448 2.5617 1.7153 2.4232 2.9828 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M80.711 72.609 c -0.1325 1.2783 -1.3091 2.1438 -2.6063 1.9539 -1.2981 -0.19 -2.2687 -1.3851 -2.1615 -2.7124 0.107 -1.3254 1.2582 -2.2397 2.594 -2.0133 1.3369 0.2265 2.3064 1.4913 2.1737 2.7719 z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M82.1189 63.4565 c -0.1418 1.2519 -1.38 2.0155 -2.7341 1.7518 -1.3555 -0.2639 -2.3671 -1.4775 -2.2569 -2.7684 0.1101 -1.2889 1.3099 -2.1371 2.711 -1.8424 1.4024 0.295 2.422 1.6049 2.28 2.8589 z" />
<path
android:fillColor="#F2690D"
android:pathData="M24.3564 13.6769 c 0.3252 2.2586 -1.0941 4.3325 -3.1576 4.7592 -2.0665 0.4274 -3.9271 -0.9504 -4.1822 -3.1604 -0.255 -2.2099 1.1547 -4.4794 3.1241 -4.9311 1.973 -0.4525 3.8904 1.0729 4.2157 3.3323 z" />
<path
android:fillColor="#F2690D"
android:pathData="M65.4155 4.8373 c 0.6897 2.6269 -0.737 4.5575 -2.9291 4.5298 -2.1997 -0.0277 -4.3526 -1.9881 -5.0235 -4.5309 -0.6692 -2.5366 0.3587 -4.7994 2.5379 -4.8357 2.1875 -0.0364 4.7231 2.2031 5.4147 4.8368 z" />
<path
android:fillColor="#F2690D"
android:pathData="M22.4411 15.8196 c 0.4039 -0.5491 1.1199 -0.6467 1.6156 -0.216 5.1717 4.4937 10.1896 9.0068 15.0872 13.6473 0.4692 0.4446 0.5007 1.187 0.0731 1.663 -0.4275 0.476 -1.1483 0.5048 -1.6131 0.0681 -4.8696 -4.5749 -9.8615 -9.0137 -15.0074 -13.4151 -0.4911 -0.42 -0.5594 -1.1982 -0.1554 -1.7474 z" />
<path
android:fillColor="#F2690D"
android:pathData="M60.9631 6.1733 c 0.6295 0.5159 0.8777 1.409 0.5531 1.9942 -3.3779 6.0895 -7.181 11.901 -11.1403 17.4699 -0.3804 0.5349 -1.0986 0.6302 -1.6054 0.2202 -0.5069 -0.41 -0.6158 -1.1754 -0.2423 -1.7185 3.8871 -5.6512 7.5636 -11.5653 10.7496 -17.8077 0.306 -0.5996 1.0558 -0.674 1.6853 -0.1581 z" />
</group> </group>
</vector> </vector>

View file

@ -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'<node[^>]*focused=\"true\"[^>]*>', xml)
print('logged in as:', texts[0] if texts else '?')
print('rows/screens:', texts[1:8])"