Compare commits

...

10 commits

Author SHA1 Message Date
73b1c86790 docs: Drop instance-specific storage-security notes
The public repo must not document this instance's object-storage
access-control posture; findings like that belong in private ops
notes. The engineering guidance (never rely on URL obscurity, always
request file tokens) stays.
2026-10-12 00:27:08 +00:00
ee5748b68d ui: Use the JTube TV logo for the launcher icon and TV banner
Replace the placeholder red play-square with the real logo (the
orange TV from https://jeena.net/jtube-logo.svg) converted to vector
drawables. The old placeholder still lives on in git history; the
launcher now matches the website branding.
2026-10-12 00:26:47 +00:00
12462e6f90 channels: Show a banner and harden D-pad activation
Channel pages now load the channel record itself and use the newest
video's thumbnail as a banner fallback, so channels never render
without a header image. Focus cards in re-sorting rows only hand out
initial focus once per screen visit so streaming stats can't steal
focus mid-navigation.

D-pad activation now also accepts ENTER/NUMPAD_ENTER/SPACE, matching
how the Compose platform fires clickable cards; consuming in the
preview phase keeps OK from double-activating.
2026-10-12 00:26:47 +00:00
1a8d8fce5b scripts: emulator-seed.sh — seed a fresh emulated device with tvbot 2026-10-11 07:47:06 +00:00
7894847144 ui: CENTER activates everything (clickable lacks DPAD_CENTER)
Compose clickable handles Enter/Space only; whether the TV's OK
(DPAD_CENTER) triggers click depends on the device's input stack -
worked on the API-36 host emulator, dead on the API-34 ATD and
possibly some TVs. A preview-tunnelled center handler now activates
drawer items, top-bar buttons, video and channel cards, and the
login/search buttons deterministically, exactly once (preview
consumption prevents the platform path from double-firing).
2026-10-11 07:44:35 +00:00
f9b0dd0dbc ui: Explicit UP from first content row to the top bar
Focus search silently failed to move from the first content card up to
the top bar (why the menu felt unreachable); the first row's cards now
have a wired UP target (the search button) in rows and grids.
2026-10-11 07:31:18 +00:00
c11638f375 ui: BACK = up the hierarchy (channel->channels->home->exit guard)
The BACK-opens-drawer behavior made navigation loops with the drawer
focus restore; replaced with the TV-standard: BACK leaves the player,
closes the drawer, leaves a channel drilldown, or goes Home; from Home
it is the double-press exit guard. Drawer opens via the account
button / LEFT on a leftmost card.
2026-10-11 07:28:19 +00:00
be221e44b1 channels: Render rows early (pages only), guard rebuild, Long-safe sort keys
The channels screen waited for every listing page AND all per-channel
stats before showing anything - a 40s+ blank spinner. Rows now render
per page (unsorted), re-sorting as stat batches arrive. rebuildRows is
guarded; the earlier ClassCastException (Integer into a Long
comparator) surfaced via the sort call and can no longer abort the
app. All sort keys go through Long-typed helpers.
2026-10-11 07:01:27 +00:00
5ebf06398d ui: Fix drawer focus-eater + BACK/RIGHT/LEFT menu affordances
The drawer's tap-to-close scrim was focusable: LEFT/RIGHT from a
drawer item moved focus onto the invisible fullscreen scrim, and OK
mysteriously dismissed - the drawer felt un-navigable. Scrim is no
longer focusable; RIGHT (from the drawer) closes it and BACK closes
it; closing restores focus to the search button. LEFT on any row's
leftmost card opens the drawer (used to be only the first row).
Banner prefetch keys now match the render keys (480x270) so channel
banners stop reloading on focus movement.
2026-10-11 06:45:56 +00:00
dd7b7ebd21 ui: All list screens use home-style 4-per-row card size
Grid screens (My videos, History, Search, channel pages) switch from
3-column to adaptive columns sized exactly like the Home rows' cards,
with the compact single-line title, so every screen reads the same.
2026-10-11 06:13:59 +00:00
18 changed files with 511 additions and 254 deletions

4
.gitignore vendored
View file

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

View file

@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a
whose media lives in a private object-storage bucket — pass the short-lived 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).
- tube.jeena.net itself streams non-public media from a **publicly readable R2 - Some instances stream non-public media from object-storage hosts with only
bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on URL-based access control — never rely on that; always request and use the
that — always request the file token for non-public videos. file token for non-public videos. (Instance-specific findings stay out of
the public repo and live in the private ops notes/agent-local instructions.)
## Conventions ## Conventions

View file

@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev
- Logout / switch account: sign-out entry exists; add switch-account (re-login screen - 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 — verified on tube.jeena.net: - Thumbnails of private/internal videos may 404 — on some instances thumbnail
instance thumbnail paths are publicly readable (HTTP 200 without auth); add a paths are publicly readable while on others they need auth; add a fallback
fallback poster only if other instances behave differently. poster only if needed.
- Security note for tube.jeena.net: object-storage (R2) media for private test - Security note (verified live against tube.jeena.net): some instances serve
videos is publicly readable without token ("private" only via URL obscurity). non-public media / thumbnails from object storage with only URL-based access
Flag to the instance admin; the app must not rely on this laxness. control. The app must not rely on that laxness; always send auth headers
/ file tokens for non-public media. (Detail intentionally omitted from the
public repo — report instance-specific findings privately.)
- Verify `searchTarget=local` channel search behaves well for instances without - 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,14 +125,20 @@ 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) total = page.total if (total == null) {
total = page.total
latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path ->
if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/')
}
}
plays += page.data.sumOf { it.views } 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) net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb)
} }
private fun unauthenticatedApiFor(base: String): PeerTubeApi = private fun unauthenticatedApiFor(base: String): PeerTubeApi =
@ -178,6 +184,9 @@ 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,6 +158,8 @@ 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,6 +83,11 @@ 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,8 +1,13 @@
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
@ -30,6 +35,12 @@ 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 {
@ -60,6 +71,15 @@ class VideoListViewModel(
} }
} }
fun loadChannelHeader(repository: PeerTubeRepository) {
val s = source
if (s is ListSource.Channel) {
viewModelScope.launch {
channelHeader = repository.videoChannel(s.handle).getOrNull()
}
}
}
fun triggerSearch(raw: String) { fun triggerSearch(raw: String) {
query.value = raw query.value = raw
activeSearch = raw.trim().ifBlank { null } activeSearch = raw.trim().ifBlank { null }

View file

@ -1,14 +1,9 @@
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
@ -19,13 +14,20 @@ 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
@ -33,65 +35,64 @@ 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.input.key.onPreviewKeyEvent
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.graphics.Color
import androidx.compose.ui.input.key.onPreviewKeyEvent
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 as SmartTube-style rows: * Channels, SmartTube-style: one row per LOCAL user.
* 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 * - Row 1: the logged-in user ("<display name>'s Channels"), then the other local
* local-only filter (verified in v8.3.1: both /accounts and /video-channels * users sorted by videos / subscribers / plays (desc).
* listings ignore local filters), so we page the whole /video-channels list * - Each row: that user's channels, same ordering; channels with no videos simply
* (max 100/page) and split by host client-side. * stay inside their user's row.
* 3. "Followed instances" — channels of instances this server follows. * - Followed-instance channels at the end, grouped per platform.
*
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
* have no isLocal filter and the listing carries no videosCount/views, so we page
* the full listing (max 100/page) and fetch per-channel stats (videos + sum of
* video views = plays) with small throttled calls.
*/ */
data class UserGroup(
val ownerName: String,
val label: String,
val avatar: Avatar?,
val channels: List<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
/** One row per (local) user, sorted by total video count; empties last. */ var userRows by mutableStateOf<List<UserGroup>>(emptyList())
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)
@ -99,87 +100,72 @@ 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" -> videos + plays; filled in the background. */ /** "name@host" -> stats; filled progressively in the background. */
var stats by mutableStateOf<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(emptyMap()) var stats by mutableStateOf<Map<String, 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 && userChannels.isNotEmpty()) return if (!force && userRows.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() ?: break val page = repo.channelList(start, 100).getOrNull()
if (page == null) {
retries++
if (retries > 2) break
delay(500)
continue
}
retries = 0
if (total == null) total = page.second 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)
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') // stats (videos + plays) for every local channel: throttled parallel-ish
val ownNames = userChannels.map { c -> c.name }.toSet() val locals = all.filter { it.host == host }
val mine = all.filter { it.host == host && it.name in ownNames } Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size +
val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } } " userChannels=" + userChannels.size)
userChannels = (userChannels + mine).distinctBy { it.name to it.host } channelStatsLoop(locals, 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 {
@ -188,11 +174,84 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
} }
} }
private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—" /** Fetch stats for [channels], re-sorting rows as data lands. */
private fun rowLabel(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.displayName private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
private fun rowAvatar(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } viewModelScope.launch {
private fun rowTotal(row: List<VideoChannelDto>) = row.sumOf { it.videosCount } for (channel in channels) {
val key = channel.name + "@" + channel.host
if (statsMap.containsKey(key)) continue
val cs = repo.channelStats(key).getOrNull()
if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue }
android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays)
statsMap[key] = cs
stats = statsMap.toMap()
rebuildRows(host)
delay(120)
}
}
}
private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L
private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L
/** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */
private fun rebuildRows(host: String?) {
runCatching { rebuildRowsInner(host) }.onFailure {
android.util.Log.e("JTube", "rebuildRows failed", it)
}
}
private fun rebuildRowsInner(host: String?) {
val locals = allChannels.filter { it.host == host }
val foreign = allChannels.filter { it.host != host }
followedRows = foreign
.groupBy { it.host ?: "?" }
.values
.map { row ->
UserGroup(
ownerName = row.firstOrNull()?.host ?: "?",
label = (platformNames[row.firstOrNull()?.host]
?: row.firstOrNull()?.host ?: "?") + " (followed)",
avatar = null,
channels = row.sortedWith(
compareByDescending<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 {
@ -212,11 +271,12 @@ fun ChannelsScreen(
) { ) {
LaunchedEffect(epoch) { viewModel.load(force = true) } LaunchedEffect(epoch) { viewModel.load(force = true) }
when { when {
viewModel.loading && viewModel.localChannels.isEmpty() -> Box( viewModel.loading && viewModel.userRows.isEmpty() -> Box(
Modifier.fillMaxSize(), contentAlignment = Alignment.Center, Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
) { androidx.compose.material3.CircularProgressIndicator() } ) { androidx.compose.material3.CircularProgressIndicator() }
viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() -> viewModel.error != null && viewModel.userRows.isEmpty() ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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)
@ -231,60 +291,28 @@ fun ChannelsScreen(
} }
} }
else -> { else -> {
val lastOpened = remember { mutableIntStateOf(-1) } // hand the initial focus to the first row exactly ONCE per screen visit:
var focusedRow by remember { mutableIntStateOf(0) } // 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").
androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) { val focusHandedOut = remember { mutableStateOf(false) }
if (viewModel.userChannels.isNotEmpty()) { LazyColumn(Modifier.fillMaxSize()) {
item { viewModel.userRows.forEach { group ->
RowHeader( item(key = "hdr-" + group.ownerName) {
avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels",
)
}
item {
ChannelRow(
viewModel.userChannels,
onOpenChannel,
onOpenDrawer,
firstFocused = true,
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
platformProvider = { viewModel.platformNames[it.host ?: ""] },
)
}
}
viewModel.localUserRows.forEach { group ->
item {
RowHeader( RowHeader(
avatar = group.avatar, avatar = group.avatar,
label = (group.label ?: "?") + " (" + group.videos + ")", label = group.label,
) )
} }
item { item(key = "row-" + group.ownerName) {
ChannelRow( ChannelRow(
group.rows, group.channels,
onOpenChannel, onOpenChannel,
{}, onOpenDrawer,
firstFocused = false, focused = !focusHandedOut.value &&
statsProvider = { viewModel.stats[it.name + "@" + it.host] }, group == viewModel.userRows.firstOrNull(),
platformProvider = { viewModel.platformNames[it.host ?: ""] }, onFocused = { focusHandedOut.value = true },
) 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 ?: ""] },
) )
} }
} }
@ -293,9 +321,9 @@ fun ChannelsScreen(
} }
} }
/** Row header: user icon + username, or plain label. */ /** Row header: user icon + "<name>'s Channels". */
@Composable @Composable
private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) { private fun RowHeader(avatar: Avatar?, label: String) {
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),
@ -303,7 +331,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
) { ) {
if (avatar != null) { if (avatar != null) {
AsyncImage( AsyncImage(
model = absolute(avatar.fileUrl ?: avatar.path), model = coil.request.ImageRequest.Builder(
androidx.compose.ui.platform.LocalContext.current,
)
.data(absoluteAvatar(avatar))
.size(80)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@ -316,24 +349,26 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
} }
} }
private fun absolute(pathOrUrl: String): String = private fun absoluteAvatar(avatar: Avatar): String =
if (pathOrUrl.startsWith("http")) pathOrUrl (avatar.fileUrl ?: avatar.path).let {
else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl if (it.startsWith("http")) it
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
}
/** Horizontal row of channel cards; eases newly focused cards into view. */ /** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
@Composable @Composable
private fun ChannelRow( private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit = {},
firstFocused: Boolean, focused: Boolean,
statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?, onFocused: () -> Unit = {},
platformProvider: (VideoChannelDto) -> String?, stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
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 { mutableIntStateOf(-1) } var focusedIndex by remember { mutableStateOf(-1) }
var clickedIndex by remember { mutableIntStateOf(-1) }
LaunchedEffect(focusedIndex) { LaunchedEffect(focusedIndex) {
if (focusedIndex > 0) { if (focusedIndex > 0) {
@ -345,13 +380,15 @@ private fun ChannelRow(
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex) if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
} }
} }
if (firstFocused) { if (focused) {
LaunchedEffect(channels.isNotEmpty()) { LaunchedEffect(channels.isNotEmpty()) {
if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() } if (channels.isNotEmpty()) {
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
}
} }
} }
androidx.compose.foundation.lazy.LazyRow( LazyRow(
state = rowState, state = rowState,
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -362,8 +399,8 @@ private fun ChannelRow(
ChannelCard( ChannelCard(
channel = channel, channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onClick = { onOpenChannel("${channel.name}@${channel.host}") },
stats = statsProvider(channel), stats = stats(channel),
platformName = platformProvider(channel), platformName = platformName(channel),
onLeftAtStart = if (index == 0) onOpenDrawer else null, onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier.focusRequester(fr), modifier = Modifier.focusRequester(fr),
) )
@ -371,16 +408,16 @@ private fun ChannelRow(
} }
} }
/** /**
* Channel card: banner top (center-cropped), round avatar bottom-left, then * Channel card: banner (center-cropped), round avatar bottom-left, channel name,
* channel name, "ownerDisplayName - platformDisplayName", stat line with icons. * "owner - platform" subtitle, then icon stats: videos | subscribers | plays.
*/ */
@Composable @Composable
fun ChannelCard( fun ChannelCard(
channel: VideoChannelDto, channel: VideoChannelDto,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
stats: net.jeena.jtubetv.data.api.ChannelStats? = null, stats: ChannelStats? = null,
platformName: String? = null, platformName: String? = null,
onLeftAtStart: (() -> Unit)? = null, onLeftAtStart: (() -> Unit)? = null,
) { ) {
@ -390,6 +427,8 @@ 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 &&
@ -418,14 +457,17 @@ 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 }
if (banner != null) { val coverUrl = banner?.let { absoluteBanner(it) }
?: 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(absolute(banner.fileUrl ?: banner.path)) .data(coverUrl)
.size(480) .size(480, 270) // same key as prefetch
.build(), .build(),
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -438,8 +480,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(absolute(avatar.fileUrl ?: avatar.path)) .data(absoluteAvatar(avatar))
.size(80) .size(80, 80)
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -471,34 +513,42 @@ fun ChannelCard(
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), Row(
verticalAlignment = Alignment.CenterVertically) { horizontalArrangement = Arrangement.spacedBy(12.dp),
StatWithIcon( verticalAlignment = Alignment.CenterVertically,
icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary, ) {
value = stats?.videos?.toString() ?: "-", StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused)
focused = cardFocused, StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), 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(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) val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { Row(
androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) 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) Text(value, style = MaterialTheme.typography.labelSmall, color = color)
} }
} }

View file

@ -153,6 +153,7 @@ 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) }
@ -188,6 +189,7 @@ fun HomeRowsScreen(
onLeftAtStart = onOpenDrawer, onLeftAtStart = onOpenDrawer,
clickedIndex = continueRestore, clickedIndex = continueRestore,
restoreToken = restoreToken, restoreToken = restoreToken,
upToTopBar = upToTopBar,
) )
} }
} }
@ -257,6 +259,7 @@ 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()
@ -315,6 +318,7 @@ 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,6 +55,7 @@ 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"),
@ -76,11 +77,13 @@ 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
var playingVideo by rememberSaveable { mutableStateOf<String?>(null) } // shortUUID // NOT saveable: launching the app should never resume a half-watched player session.
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).
@ -88,34 +91,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
if (playingVideo == null && restoreToken >= 1) restoreToken++ if (playingVideo == null && restoreToken >= 1) restoreToken++
} }
// BACK order: player (own BackHandler) → drawer close → channel drilldown → // BACK order (TV standard): player (own handler) → drawer close → channel
// BACK (on content) opens the drawer; a double-BACK with the drawer open exits. // drilldown → non-Home destination → Home; from Home: double-press exits.
BackHandler(enabled = playingVideo == null && channelDetails == null) { BackHandler(enabled = drawerOpen && playingVideo == null) {
val now = System.currentTimeMillis() drawerOpen = false
if (drawerOpen) { runCatching { searchFocus.requestFocus() }
}
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
channelDetails = null
}
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) {
if (destination != Destination.Home) {
channelDetails = null
destination = Destination.Home
} else {
val now = System.currentTimeMillis()
if (now - lastBackAt < 2500) { 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
@ -142,6 +140,7 @@ 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
@ -156,6 +155,7 @@ 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,6 +164,7 @@ 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 },
@ -177,6 +178,7 @@ 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(
@ -184,6 +186,7 @@ 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(
@ -192,6 +195,7 @@ 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() } },
) )
} }
} }
@ -236,6 +240,7 @@ 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(
@ -249,6 +254,7 @@ 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) }
@ -261,6 +267,7 @@ 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),
@ -317,6 +324,7 @@ 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(
@ -330,7 +338,9 @@ 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,
) { ) {
@ -364,7 +374,16 @@ 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",
@ -401,12 +420,11 @@ private fun MenuDrawer(
modifier = Modifier, modifier = Modifier,
) )
} }
// tap/click outside closes; focus LEFT from the drawer also closes // tap/click outside closes the drawer
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .fillMaxSize()
.focusable()
.pointerInput(Unit) { detectTapGestures { onDismiss() } }, .pointerInput(Unit) { detectTapGestures { onDismiss() } },
) )
} }
@ -439,6 +457,7 @@ 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,6 +28,7 @@ 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
@ -164,9 +165,6 @@ 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() ->
@ -178,7 +176,20 @@ fun SetupScreen(
} }
}, },
enabled = !busy.value, enabled = !busy.value,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.dpadCenterActivatable(
onClick = {
when {
url.value.isBlank() || username.value.isBlank() || password.value.isBlank() ->
localError.value = "All fields are required"
else -> {
localError.value = null
onLogin(url.value, username.value, password.value)
}
}
},
),
) { ) {
if (busy.value) { if (busy.value) {
CircularProgressIndicator(Modifier.width(20.dp)) CircularProgressIndicator(Modifier.width(20.dp))

View file

@ -82,6 +82,7 @@ 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 {
@ -157,6 +158,7 @@ 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

@ -0,0 +1,29 @@
package net.jeena.jtubetv.ui.screens.list
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.key.onPreviewKeyEvent
/**
* Compose `clickable` handles Enter/Space but NOT the TV's DPAD_CENTER; whether
* CENTER works depends on each device's input translation (works on some emulators,
* not on others — see the API-34 ATD case). This modifier consumes CENTER *first*
* (preview/tunneling) so the click fires exactly once, on every device.
*/
fun Modifier.dpadCenterActivatable(onClick: () -> Unit): Modifier = composed {
onPreviewKeyEvent { ke ->
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) {
val code = ke.nativeKeyEvent.keyCode
val activates = code == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
code == android.view.KeyEvent.KEYCODE_ENTER ||
code == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
code == android.view.KeyEvent.KEYCODE_SPACE
if (activates) {
onClick()
true // consumed in preview -> the platform/card path never double-fires
} else false
} else false
}
}

View file

@ -59,6 +59,8 @@ 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,
) { ) {
@ -67,17 +69,23 @@ 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 ->
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown && val direction = ke.key
ke.key == androidx.compose.ui.input.key.Key.DirectionLeft && if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) {
onLeftAtStart != null when {
) { direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> {
onLeftAtStart() onLeftAtStart(); true
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,6 +33,7 @@ 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
@ -44,6 +45,7 @@ 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),
@ -120,7 +122,8 @@ fun VideoListScreen(
} }
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), // Same card size as the Home rows: ~4 fit per row (user request).
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
state = gridState, state = gridState,
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp), modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
) { ) {
@ -136,7 +139,9 @@ 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,16 +7,43 @@
<path <path
android:fillColor="#101010" android:fillColor="#101010"
android:pathData="M0,0h320v180h-320z" /> android:pathData="M0,0h320v180h-320z" />
<path <group
android:fillColor="#CE2B2B" android:scaleX="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:scaleY="1.2"
<path android:translateX="106.9"
android:fillColor="#FFFFFF" android:translateY="34.9">
android:pathData="M60,62l26,20l-26,20z" /> <path
<path android:fillColor="#F2690D"
android:fillColor="#FFFFFF" 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="M128,76h160v10h-160z" /> <path
<path android:fillColor="#F2690D"
android:fillColor="#66FFFFFF" 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:pathData="M128,100h120v8h-120z" /> <path
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,13 +5,42 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <group
android:translateX="30" android:scaleX="0.6"
android:translateY="30"> android:scaleY="0.6"
android:translateX="27.5"
android:translateY="26.5">
<path <path
android:fillColor="#CE2B2B" android:fillColor="#F2690D"
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="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" />
<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="M20,21l13,10l-13,10z" /> 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> </group>
</vector> </vector>

32
scripts/emulator-seed.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# emulator-seed.sh — (container-side) log the freshly booted emulated device into the
# JTube test account via the debug-only seed intent. Usage:
# ./emulator-seed.sh emulator-5568 [serial]
set -euo pipefail
SERIAL="${1:-emulator-5568}"
ADB="${ANDROID_HOME:-$HOME/Android/Sdk}/platform-tools/adb"
[ -x "$ADB" ] && ADB="$ADB" || ADB="$(which adb)"
# credentials from the encrypted store
eval "$(sops -d --output-type dotenv "$HOME/.agents/secrets/jtube.env" | sed 's/^/export /')"
# kill + cold start so onCreate (the debug seeder) actually runs
"$ADB" -s "$SERIAL" shell am force-stop net.jeena.jtubetv 2>/dev/null || true
"$ADB" -s "$SERIAL" install -r "$HOME/Projects/jtube-tv/app/build/outputs/apk/debug/app-debug.apk" 2>&1 | tail -1
"$ADB" -s "$SERIAL" shell am start -a android.intent.action.MAIN \
-n net.jeena.jtubetv/.MainActivity \
--es jtube_url "$JTUBE_URL" \
--es jtube_user "$JTUBE_USER" \
--es jtube_pass "$JTUBE_PASS"
sleep 12
"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1
"$ADB" -s "$SERIAL" shell cat /sdcard/ui.xml | python3 -c "
import sys, re
xml = sys.stdin.read()
texts = [m.group(1) for m in re.finditer(r'text=\"([^\"]{2,40})\"', xml)]
foc = re.search(r'<node[^>]*focused=\"true\"[^>]*>', xml)
print('logged in as:', texts[0] if texts else '?')
print('rows/screens:', texts[1:8])"