Compare commits
10 commits
d74a71758b
...
73b1c86790
| Author | SHA1 | Date | |
|---|---|---|---|
| 73b1c86790 | |||
| ee5748b68d | |||
| 12462e6f90 | |||
| 1a8d8fce5b | |||
| 7894847144 | |||
| f9b0dd0dbc | |||
| c11638f375 | |||
| be221e44b1 | |||
| 5ebf06398d | |||
| dd7b7ebd21 |
18 changed files with 511 additions and 254 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,4 +7,6 @@
|
|||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxxreports/*-shots/
|
||||
.cxx
|
||||
reports/*-shots/
|
||||
reports/*.md
|
||||
|
|
|
|||
|
|
@ -69,9 +69,10 @@ setup/login screen, `Ready` renders `HomeScreen`, which switches content on a
|
|||
whose media lives in a private object-storage bucket — pass the short-lived
|
||||
`POST /api/v1/videos/{shortUuid}/token` token as `?videoFileToken=` on those URLs
|
||||
(response shape: `{ files: { token, expires } }`; verified live).
|
||||
- tube.jeena.net itself streams non-public media from a **publicly readable R2
|
||||
bucket** (`pub-….r2.dev`): no auth is enforced there today, but do not rely on
|
||||
that — always request the file token for non-public videos.
|
||||
- Some instances stream non-public media from object-storage hosts with only
|
||||
URL-based access control — never rely on that; always request and use the
|
||||
file token for non-public videos. (Instance-specific findings stay out of
|
||||
the public repo and live in the private ops notes/agent-local instructions.)
|
||||
|
||||
## Conventions
|
||||
|
||||
|
|
|
|||
14
BACKLOG.md
14
BACKLOG.md
|
|
@ -38,12 +38,14 @@ focus colors, always-focusable Sign in with inline validation. Deferred from rev
|
|||
- Logout / switch account: sign-out entry exists; add switch-account (re-login screen
|
||||
without losing scroll state).
|
||||
- Release build: enable `minifyEnabled` + R8 rules, decide signing/versioning.
|
||||
- Thumbnails of private/internal videos may 404 — verified on tube.jeena.net:
|
||||
instance thumbnail paths are publicly readable (HTTP 200 without auth); add a
|
||||
fallback poster only if other instances behave differently.
|
||||
- Security note for tube.jeena.net: object-storage (R2) media for private test
|
||||
videos is publicly readable without token ("private" only via URL obscurity).
|
||||
Flag to the instance admin; the app must not rely on this laxness.
|
||||
- Thumbnails of private/internal videos may 404 — on some instances thumbnail
|
||||
paths are publicly readable while on others they need auth; add a fallback
|
||||
poster only if needed.
|
||||
- Security note (verified live against tube.jeena.net): some instances serve
|
||||
non-public media / thumbnails from object storage with only URL-based access
|
||||
control. The app must not rely on that laxness; always send auth headers
|
||||
/ file tokens for non-public media. (Detail intentionally omitted from the
|
||||
public repo — report instance-specific findings privately.)
|
||||
- Verify `searchTarget=local` channel search behaves well for instances without
|
||||
federation; consider `search/video-channels` endpoint as an alternative.
|
||||
- Release build: enable `minifyEnabled` + R8 rules, decide on signing, versioning scheme.
|
||||
|
|
|
|||
|
|
@ -125,14 +125,20 @@ class PeerTubeRepository(
|
|||
var videos = 0L
|
||||
var plays = 0L
|
||||
var total: Long? = null
|
||||
var latestThumb: String? = null
|
||||
repeat(maxPages) {
|
||||
val page = network.api.channelVideos(handle, start, 25)
|
||||
if (total == null) total = page.total
|
||||
if (total == null) {
|
||||
total = page.total
|
||||
latestThumb = page.data.firstOrNull()?.thumbnailPath?.let { path ->
|
||||
if (path.startsWith("http")) path else baseUrl + "/" + path.trimStart('/')
|
||||
}
|
||||
}
|
||||
plays += page.data.sumOf { it.views }
|
||||
start += page.data.size
|
||||
videos = total ?: videos
|
||||
}
|
||||
net.jeena.jtubetv.data.api.ChannelStats(videos, plays)
|
||||
net.jeena.jtubetv.data.api.ChannelStats(videos, plays, latestThumb)
|
||||
}
|
||||
|
||||
private fun unauthenticatedApiFor(base: String): PeerTubeApi =
|
||||
|
|
@ -178,6 +184,9 @@ class PeerTubeRepository(
|
|||
response.data to response.total
|
||||
}
|
||||
|
||||
suspend fun videoChannel(handle: String): Result<VideoChannelDto> =
|
||||
runCatching { network.api.videoChannel(handle) }
|
||||
|
||||
suspend fun accountChannels(username: String): Result<List<VideoChannelDto>> =
|
||||
runCatching {
|
||||
val response: VideoChannelListResponse = network.api.accountChannels(username)
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ data class InstanceInfoDto(
|
|||
data class ChannelStats(
|
||||
val videos: Long,
|
||||
val plays: Long,
|
||||
/** Thumbnail of the newest video — banner fallback for channels without one. */
|
||||
val latestThumbUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ interface PeerTubeApi {
|
|||
@Path("account") account: String,
|
||||
): VideoChannelListResponse
|
||||
|
||||
@GET("video-channels/{handle}")
|
||||
suspend fun videoChannel(
|
||||
@Path("handle") handle: String,
|
||||
): VideoChannelDto
|
||||
|
||||
@GET("video-channels/{handle}/videos")
|
||||
suspend fun channelVideos(
|
||||
@Path("handle") handle: String,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
package net.jeena.jtubetv.ui.list
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||
|
||||
|
|
@ -30,6 +35,12 @@ class VideoListViewModel(
|
|||
val query = MutableStateFlow("")
|
||||
private var activeSearch: String? = null
|
||||
|
||||
/** For channel pages: the channel itself (banner + display name) as page header. */
|
||||
var channelHeader by androidx.compose.runtime.mutableStateOf<net.jeena.jtubetv.data.api.VideoChannelDto?>(
|
||||
null,
|
||||
)
|
||||
private set
|
||||
|
||||
override fun onChunkLoaded(chunk: List<VideoListItem>) {
|
||||
ImagePrefetcher.videoThumbnails(
|
||||
chunk.mapNotNull {
|
||||
|
|
@ -60,6 +71,15 @@ class VideoListViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun loadChannelHeader(repository: PeerTubeRepository) {
|
||||
val s = source
|
||||
if (s is ListSource.Channel) {
|
||||
viewModelScope.launch {
|
||||
channelHeader = repository.videoChannel(s.handle).getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerSearch(raw: String) {
|
||||
query.value = raw
|
||||
activeSearch = raw.trim().ifBlank { null }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
package net.jeena.jtubetv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -19,13 +14,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.VideoLibrary
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -33,65 +35,64 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import coil.compose.AsyncImage
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.data.PeerTubeRepository
|
||||
import net.jeena.jtubetv.data.auth.TokenStore
|
||||
import net.jeena.jtubetv.data.api.Avatar
|
||||
import net.jeena.jtubetv.data.api.ChannelStats
|
||||
import net.jeena.jtubetv.data.api.UserDto
|
||||
import net.jeena.jtubetv.data.api.VideoChannelDto
|
||||
import net.jeena.jtubetv.ui.list.ImagePrefetcher
|
||||
import net.jeena.jtubetv.ui.screens.list.ContainerShape
|
||||
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg
|
||||
|
||||
/** One row of channels belonging to one user (avatar + display name as header). */
|
||||
data class UserGroup(
|
||||
val label: String?,
|
||||
val avatar: Avatar?,
|
||||
val videos: Long,
|
||||
val rows: List<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
|
||||
}
|
||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
||||
|
||||
/**
|
||||
* Channels as SmartTube-style rows:
|
||||
* 1. "Your channels" — header with account avatar + username, then own channel cards.
|
||||
* 2. "Local channels" — the instance's own channels. The public API has NO
|
||||
* local-only filter (verified in v8.3.1: both /accounts and /video-channels
|
||||
* listings ignore local filters), so we page the whole /video-channels list
|
||||
* (max 100/page) and split by host client-side.
|
||||
* 3. "Followed instances" — channels of instances this server follows.
|
||||
* Channels, SmartTube-style: one row per LOCAL user.
|
||||
*
|
||||
* - Row 1: the logged-in user ("<display name>'s Channels"), then the other local
|
||||
* users sorted by videos / subscribers / plays (desc).
|
||||
* - Each row: that user's channels, same ordering; channels with no videos simply
|
||||
* stay inside their user's row.
|
||||
* - Followed-instance channels at the end, grouped per platform.
|
||||
*
|
||||
* API limits (verified, PeerTube 8.3.1): the /video-channels and /accounts listings
|
||||
* have no isLocal filter and the listing carries no videosCount/views, so we page
|
||||
* the full listing (max 100/page) and fetch per-channel stats (videos + sum of
|
||||
* video views = plays) with small throttled calls.
|
||||
*/
|
||||
data class UserGroup(
|
||||
val ownerName: String,
|
||||
val label: String,
|
||||
val avatar: Avatar?,
|
||||
val channels: List<VideoChannelDto>,
|
||||
)
|
||||
|
||||
class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
||||
|
||||
var user by mutableStateOf<UserDto?>(null)
|
||||
private set
|
||||
var userChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
private set
|
||||
var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
private set
|
||||
|
||||
/** 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())
|
||||
var userRows by mutableStateOf<List<UserGroup>>(emptyList())
|
||||
private set
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
|
|
@ -99,87 +100,72 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
/** "name@host" -> videos + plays; filled in the background. */
|
||||
var stats by mutableStateOf<Map<String, net.jeena.jtubetv.data.api.ChannelStats>>(emptyMap())
|
||||
/** "name@host" -> stats; filled progressively in the background. */
|
||||
var stats by mutableStateOf<Map<String, ChannelStats>>(emptyMap())
|
||||
private set
|
||||
|
||||
/** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */
|
||||
var platformNames by mutableStateOf<Map<String, String>>(emptyMap())
|
||||
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 statsMap = HashMap<String, ChannelStats>()
|
||||
|
||||
|
||||
fun load(force: Boolean = false) {
|
||||
if (!force && userChannels.isNotEmpty()) return
|
||||
if (!force && userRows.isNotEmpty()) return
|
||||
loading = true
|
||||
error = null
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// The user's own row renders FIRST (fast: one call), the rest streams in.
|
||||
user = repo.me().getOrNull()
|
||||
userChannels = user?.let { u ->
|
||||
repo.accountChannels(u.username).getOrNull() ?: emptyList()
|
||||
} ?: emptyList()
|
||||
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
|
||||
platformNames = mapOf((host ?: "") to (store.instanceName ?: host ?: ""))
|
||||
|
||||
// enum the whole channel list (federation-follow listing incl. local)
|
||||
val all = mutableListOf<VideoChannelDto>()
|
||||
var start = 0L
|
||||
var total: Long? = null
|
||||
var retries = 0
|
||||
while (total == null || start < total) {
|
||||
val page = repo.channelList(start, 100).getOrNull() ?: break
|
||||
val page = repo.channelList(start, 100).getOrNull()
|
||||
if (page == null) {
|
||||
retries++
|
||||
if (retries > 2) break
|
||||
delay(500)
|
||||
continue
|
||||
}
|
||||
retries = 0
|
||||
if (total == null) total = page.second
|
||||
val known = all.map { it.name to it.host }.toSet()
|
||||
all += page.first.filter { c -> (c.name to c.host) !in known }
|
||||
start += 100
|
||||
android.util.Log.w(
|
||||
"JTube",
|
||||
"channels page " + (start / 100) + ": total=" + total + " all=" + all.size +
|
||||
" locals=" + all.count { it.host == host },
|
||||
)
|
||||
allChannels = all
|
||||
rebuildRows(host)
|
||||
}
|
||||
ImagePrefetcher.channelArt(all + userChannels)
|
||||
|
||||
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
|
||||
val ownNames = userChannels.map { c -> c.name }.toSet()
|
||||
val mine = all.filter { it.host == host && it.name in ownNames }
|
||||
val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } }
|
||||
userChannels = (userChannels + mine).distinctBy { it.name to it.host }
|
||||
localChannels = rest.filter { it.host == host }
|
||||
|
||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all)
|
||||
|
||||
// platform display names (local cached in prefs; foreign lazy)
|
||||
platformNames = mapOf(
|
||||
(host ?: "") to (store.instanceName ?: host ?: ""),
|
||||
)
|
||||
val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "")
|
||||
for (fh in foreignHosts) {
|
||||
val name = repo.instanceName(fh).getOrNull() ?: continue
|
||||
platformNames = platformNames + (fh to name)
|
||||
}
|
||||
|
||||
// video + play counts per channel, one tiny listing call per channel
|
||||
for (channel in (userChannels + localChannels)) {
|
||||
val key = channel.name + "@" + channel.host
|
||||
val cs = repo.channelStats(key).getOrNull() ?: continue
|
||||
stats = stats + (key to cs)
|
||||
}
|
||||
// group LOCAL channels by owner (user): row per user, videos desc,
|
||||
// channels with zero videos collected into a single last row.
|
||||
val locals = rest.filter { it.host == host }
|
||||
val withVideos = locals.filter { it.videosCount > 0 }
|
||||
val empty = locals.filter { it.videosCount <= 0 }
|
||||
val byOwner = withVideos.groupBy { ownerKey(it) }
|
||||
localUserRows =
|
||||
byOwner.values
|
||||
.map { row -> UserGroup(
|
||||
label = rowLabel(row),
|
||||
avatar = rowAvatar(row),
|
||||
videos = rowTotal(row),
|
||||
rows = row.sortedByDescending { it.videosCount },
|
||||
) }
|
||||
.filter { it.label != null }
|
||||
.sortedByDescending { it.rowWeight() }
|
||||
if (empty.isNotEmpty()) {
|
||||
localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty)
|
||||
}
|
||||
followedRows = rest.filter { it.host != host }
|
||||
.groupBy { it.host ?: "?" }
|
||||
.values
|
||||
.map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) }
|
||||
.sortedByDescending { it.rowWeight() }
|
||||
// stats (videos + plays) for every local channel: throttled parallel-ish
|
||||
val locals = all.filter { it.host == host }
|
||||
Log.w("JTube", "pages done: all=" + all.size + " locals=" + locals.size +
|
||||
" userChannels=" + userChannels.size)
|
||||
channelStatsLoop(locals, host)
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Could not load channels"
|
||||
} finally {
|
||||
|
|
@ -188,11 +174,84 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ownerKey(c: VideoChannelDto) = c.ownerAccount?.name ?: "—"
|
||||
private fun rowLabel(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.displayName
|
||||
private fun rowAvatar(row: List<VideoChannelDto>) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width }
|
||||
private fun rowTotal(row: List<VideoChannelDto>) = row.sumOf { it.videosCount }
|
||||
/** Fetch stats for [channels], re-sorting rows as data lands. */
|
||||
private fun channelStatsLoop(channels: List<VideoChannelDto>, host: String?) {
|
||||
viewModelScope.launch {
|
||||
for (channel in channels) {
|
||||
val key = channel.name + "@" + channel.host
|
||||
if (statsMap.containsKey(key)) continue
|
||||
val cs = repo.channelStats(key).getOrNull()
|
||||
if (cs == null) { android.util.Log.w("JTube", "stats FAIL " + key); continue }
|
||||
android.util.Log.w("JTube", "stats " + key + " videos=" + cs.videos + " plays=" + cs.plays)
|
||||
statsMap[key] = cs
|
||||
stats = statsMap.toMap()
|
||||
rebuildRows(host)
|
||||
delay(120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.videos ?: 0L
|
||||
private fun playsOf(c: VideoChannelDto): Long = statsMap[c.name + "@" + c.host]?.plays ?: 0L
|
||||
|
||||
/** Rebuilds the per-user rows from current channels+stats (sorted videos/subs/plays). */
|
||||
private fun rebuildRows(host: String?) {
|
||||
runCatching { rebuildRowsInner(host) }.onFailure {
|
||||
android.util.Log.e("JTube", "rebuildRows failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rebuildRowsInner(host: String?) {
|
||||
val locals = allChannels.filter { it.host == host }
|
||||
val foreign = allChannels.filter { it.host != host }
|
||||
followedRows = foreign
|
||||
.groupBy { it.host ?: "?" }
|
||||
.values
|
||||
.map { row ->
|
||||
UserGroup(
|
||||
ownerName = row.firstOrNull()?.host ?: "?",
|
||||
label = (platformNames[row.firstOrNull()?.host]
|
||||
?: row.firstOrNull()?.host ?: "?") + " (followed)",
|
||||
avatar = null,
|
||||
channels = row.sortedWith(
|
||||
compareByDescending<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 {
|
||||
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
|
|
@ -212,11 +271,12 @@ fun ChannelsScreen(
|
|||
) {
|
||||
LaunchedEffect(epoch) { viewModel.load(force = true) }
|
||||
|
||||
|
||||
when {
|
||||
viewModel.loading && viewModel.localChannels.isEmpty() -> Box(
|
||||
viewModel.loading && viewModel.userRows.isEmpty() -> Box(
|
||||
Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
|
||||
) { androidx.compose.material3.CircularProgressIndicator() }
|
||||
viewModel.error != null && viewModel.localChannels.isEmpty() && viewModel.userChannels.isEmpty() ->
|
||||
viewModel.error != null && viewModel.userRows.isEmpty() ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Could not load channels", color = MaterialTheme.colorScheme.error)
|
||||
|
|
@ -231,60 +291,28 @@ fun ChannelsScreen(
|
|||
}
|
||||
}
|
||||
else -> {
|
||||
val lastOpened = remember { mutableIntStateOf(-1) }
|
||||
var focusedRow by remember { mutableIntStateOf(0) }
|
||||
|
||||
androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) {
|
||||
if (viewModel.userChannels.isNotEmpty()) {
|
||||
item {
|
||||
RowHeader(
|
||||
avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
|
||||
label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels",
|
||||
)
|
||||
}
|
||||
item {
|
||||
ChannelRow(
|
||||
viewModel.userChannels,
|
||||
onOpenChannel,
|
||||
onOpenDrawer,
|
||||
firstFocused = true,
|
||||
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformProvider = { viewModel.platformNames[it.host ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.localUserRows.forEach { group ->
|
||||
item {
|
||||
// hand the initial focus to the first row exactly ONCE per screen visit:
|
||||
// as stats stream in the rows re-sort, and re-focusing their first card
|
||||
// would steal focus mid-navigation (user: "up jumps to the first row").
|
||||
val focusHandedOut = remember { mutableStateOf(false) }
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
viewModel.userRows.forEach { group ->
|
||||
item(key = "hdr-" + group.ownerName) {
|
||||
RowHeader(
|
||||
avatar = group.avatar,
|
||||
label = (group.label ?: "?") + " (" + group.videos + ")",
|
||||
label = group.label,
|
||||
)
|
||||
}
|
||||
item {
|
||||
item(key = "row-" + group.ownerName) {
|
||||
ChannelRow(
|
||||
group.rows,
|
||||
group.channels,
|
||||
onOpenChannel,
|
||||
{},
|
||||
firstFocused = false,
|
||||
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformProvider = { viewModel.platformNames[it.host ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.followedRows.forEach { group ->
|
||||
item {
|
||||
RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")")
|
||||
}
|
||||
item {
|
||||
ChannelRow(
|
||||
group.rows,
|
||||
onOpenChannel,
|
||||
{},
|
||||
firstFocused = false,
|
||||
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformProvider = { viewModel.platformNames[it.host ?: ""] },
|
||||
onOpenDrawer,
|
||||
focused = !focusHandedOut.value &&
|
||||
group == viewModel.userRows.firstOrNull(),
|
||||
onFocused = { focusHandedOut.value = true },
|
||||
stats = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformName = { viewModel.platformNames[it.host ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -293,9 +321,9 @@ fun ChannelsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
/** Row header: user icon + username, or plain label. */
|
||||
/** Row header: user icon + "<name>'s Channels". */
|
||||
@Composable
|
||||
private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit = {}) {
|
||||
private fun RowHeader(avatar: Avatar?, label: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
|
|
@ -303,7 +331,12 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
|
|||
) {
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
model = absolute(avatar.fileUrl ?: avatar.path),
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(absoluteAvatar(avatar))
|
||||
.size(80)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
|
|
@ -316,24 +349,26 @@ private fun RowHeader(avatar: Avatar?, label: String, onOpenDrawer: () -> Unit =
|
|||
}
|
||||
}
|
||||
|
||||
private fun absolute(pathOrUrl: String): String =
|
||||
if (pathOrUrl.startsWith("http")) pathOrUrl
|
||||
else (AppGraph.tokenStore.baseUrl ?: "") + (if (pathOrUrl.startsWith("/")) "" else "/") + pathOrUrl
|
||||
private fun absoluteAvatar(avatar: Avatar): String =
|
||||
(avatar.fileUrl ?: avatar.path).let {
|
||||
if (it.startsWith("http")) it
|
||||
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
|
||||
}
|
||||
|
||||
/** Horizontal row of channel cards; eases newly focused cards into view. */
|
||||
/** Horizontal row of channel cards, eased scroll-in; LEFT at row start -> drawer. */
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channels: List<VideoChannelDto>,
|
||||
onOpenChannel: (String) -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
firstFocused: Boolean,
|
||||
statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||
platformProvider: (VideoChannelDto) -> String?,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
focused: Boolean,
|
||||
onFocused: () -> Unit = {},
|
||||
stats: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||
platformName: (VideoChannelDto) -> String?,
|
||||
) {
|
||||
val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
|
||||
var focusedIndex by remember { mutableIntStateOf(-1) }
|
||||
var clickedIndex by remember { mutableIntStateOf(-1) }
|
||||
var focusedIndex by remember { mutableStateOf(-1) }
|
||||
|
||||
LaunchedEffect(focusedIndex) {
|
||||
if (focusedIndex > 0) {
|
||||
|
|
@ -345,13 +380,15 @@ private fun ChannelRow(
|
|||
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||
}
|
||||
}
|
||||
if (firstFocused) {
|
||||
if (focused) {
|
||||
LaunchedEffect(channels.isNotEmpty()) {
|
||||
if (channels.isNotEmpty()) runCatching { focusRequesters[0]?.requestFocus() }
|
||||
if (channels.isNotEmpty()) {
|
||||
if (runCatching { focusRequesters[0]?.requestFocus() }.isSuccess) onFocused()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
@ -362,8 +399,8 @@ private fun ChannelRow(
|
|||
ChannelCard(
|
||||
channel = channel,
|
||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||
stats = statsProvider(channel),
|
||||
platformName = platformProvider(channel),
|
||||
stats = stats(channel),
|
||||
platformName = platformName(channel),
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
modifier = Modifier.focusRequester(fr),
|
||||
)
|
||||
|
|
@ -371,16 +408,16 @@ private fun ChannelRow(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel card: banner top (center-cropped), round avatar bottom-left, then
|
||||
* channel name, "ownerDisplayName - platformDisplayName", stat line with icons.
|
||||
/**
|
||||
* Channel card: banner (center-cropped), round avatar bottom-left, channel name,
|
||||
* "owner - platform" subtitle, then icon stats: videos | subscribers | plays.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
channel: VideoChannelDto,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
stats: net.jeena.jtubetv.data.api.ChannelStats? = null,
|
||||
stats: ChannelStats? = null,
|
||||
platformName: String? = null,
|
||||
onLeftAtStart: (() -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -390,6 +427,8 @@ fun ChannelCard(
|
|||
modifier = modifier
|
||||
.width(rowCardWidth())
|
||||
.padding(6.dp)
|
||||
.dpadCenterActivatable(onClick)
|
||||
.onFocusChanged { cardFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT &&
|
||||
|
|
@ -418,14 +457,17 @@ fun ChannelCard(
|
|||
.aspectRatio(16f / 9f)
|
||||
.background(Color(0xFF222222)),
|
||||
) {
|
||||
// banner, or the newest video's thumbnail for bannerless channels
|
||||
val banner = channel.banners.maxByOrNull { it.width }
|
||||
if (banner != null) {
|
||||
val coverUrl = banner?.let { absoluteBanner(it) }
|
||||
?: stats?.latestThumbUrl
|
||||
if (coverUrl != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(absolute(banner.fileUrl ?: banner.path))
|
||||
.size(480)
|
||||
.data(coverUrl)
|
||||
.size(480, 270) // same key as prefetch
|
||||
.build(),
|
||||
contentDescription = channel.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -438,8 +480,8 @@ fun ChannelCard(
|
|||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(absolute(avatar.fileUrl ?: avatar.path))
|
||||
.size(80)
|
||||
.data(absoluteAvatar(avatar))
|
||||
.size(80, 80)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -471,34 +513,42 @@ fun ChannelCard(
|
|||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
StatWithIcon(
|
||||
icon = androidx.compose.material.icons.Icons.Filled.VideoLibrary,
|
||||
value = stats?.videos?.toString() ?: "-",
|
||||
focused = cardFocused,
|
||||
)
|
||||
StatWithIcon(
|
||||
icon = androidx.compose.material.icons.Icons.Filled.Groups,
|
||||
value = channel.followersCount.toString(),
|
||||
focused = cardFocused,
|
||||
)
|
||||
StatWithIcon(
|
||||
icon = androidx.compose.material.icons.Icons.Filled.PlayArrow,
|
||||
value = stats?.plays?.toString() ?: "-",
|
||||
focused = cardFocused,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatWithIcon(Icons.Filled.VideoLibrary, stats?.videos?.toString() ?: "-", cardFocused)
|
||||
StatWithIcon(Icons.Filled.Groups, channel.followersCount.toString(), cardFocused)
|
||||
StatWithIcon(Icons.Filled.PlayArrow, stats?.plays?.toString() ?: "-", cardFocused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun absoluteBanner(banner: Avatar): String =
|
||||
(banner.fileUrl ?: banner.path).let {
|
||||
if (it.startsWith("http")) it
|
||||
else (AppGraph.tokenStore.baseUrl ?: "") + (if (it.startsWith("/")) "" else "/") + it
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatWithIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, focused: Boolean) {
|
||||
private fun StatWithIcon(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
value: String,
|
||||
focused: Boolean,
|
||||
) {
|
||||
val color = if (focused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
androidx.tv.material3.Icon(imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
androidx.tv.material3.Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,7 @@ fun HomeRowsScreen(
|
|||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
upToTopBar: (() -> Unit)? = null,
|
||||
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
|
||||
) {
|
||||
val continueRestore = androidx.compose.runtime.remember { androidx.compose.runtime.mutableIntStateOf(-1) }
|
||||
|
|
@ -188,6 +189,7 @@ fun HomeRowsScreen(
|
|||
onLeftAtStart = onOpenDrawer,
|
||||
clickedIndex = continueRestore,
|
||||
restoreToken = restoreToken,
|
||||
upToTopBar = upToTopBar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -257,6 +259,7 @@ fun FocusScrollRow(
|
|||
onLeftAtStart: () -> Unit,
|
||||
clickedIndex: androidx.compose.runtime.MutableIntState? = null, // set on card click
|
||||
restoreToken: Int = 0, // bumped when playback ends
|
||||
upToTopBar: (() -> Unit)? = null, // first content row only
|
||||
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
|
@ -315,6 +318,7 @@ fun FocusScrollRow(
|
|||
compact = true,
|
||||
progress = progressOf(video.shortUUID),
|
||||
onLeftAtStart = if (index == 0) onLeftAtStart else null,
|
||||
onUpToTopBar = upToTopBar,
|
||||
onFocused = { focusedIndex = index },
|
||||
)
|
||||
afterItem(index, video)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import net.jeena.jtubetv.data.api.VideoDto
|
|||
import net.jeena.jtubetv.ui.AppViewModel
|
||||
import net.jeena.jtubetv.ui.list.ListSource
|
||||
import net.jeena.jtubetv.ui.screens.list.VideoListScreen
|
||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
||||
|
||||
enum class Destination(val title: String) {
|
||||
Home("Home"),
|
||||
|
|
@ -76,11 +77,13 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
val epoch by viewModel.sessionEpoch.collectAsState()
|
||||
var destination by rememberSaveable { mutableStateOf(Destination.Home) }
|
||||
var channelDetails by rememberSaveable { mutableStateOf<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 drawerOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
var lastBackAt by rememberSaveable { mutableStateOf(0L) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
|
||||
// After BACK from the player: bump the restore token so lists re-focus the opened
|
||||
// card and re-fetch positions (UX report BUG-3 + BUG-7).
|
||||
|
|
@ -88,34 +91,29 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
if (playingVideo == null && restoreToken >= 1) restoreToken++
|
||||
}
|
||||
|
||||
// BACK order: player (own BackHandler) → drawer close → channel drilldown →
|
||||
// BACK (on content) opens the drawer; a double-BACK with the drawer open exits.
|
||||
BackHandler(enabled = playingVideo == null && channelDetails == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (drawerOpen) {
|
||||
// BACK order (TV standard): player (own handler) → drawer close → channel
|
||||
// drilldown → non-Home destination → Home; from Home: double-press exits.
|
||||
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
||||
drawerOpen = false
|
||||
runCatching { searchFocus.requestFocus() }
|
||||
}
|
||||
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails != null) {
|
||||
channelDetails = null
|
||||
}
|
||||
BackHandler(enabled = !drawerOpen && playingVideo == null && channelDetails == null) {
|
||||
if (destination != Destination.Home) {
|
||||
channelDetails = null
|
||||
destination = Destination.Home
|
||||
} else {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
return@BackHandler
|
||||
}
|
||||
lastBackAt = now
|
||||
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
lastBackAt = now
|
||||
drawerOpen = true
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) {
|
||||
channelDetails = null
|
||||
}
|
||||
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
return@BackHandler
|
||||
}
|
||||
lastBackAt = now
|
||||
drawerOpen = false
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -142,6 +140,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
TopBar(
|
||||
onSearchClick = { destination = Destination.Search; channelDetails = null },
|
||||
onAccountClick = { drawerOpen = !drawerOpen },
|
||||
searchFocus = searchFocus,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -156,6 +155,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
||||
)
|
||||
Destination.MyVideos ->
|
||||
VideoListScreen(
|
||||
|
|
@ -164,6 +164,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
||||
)
|
||||
Destination.Channels -> ChannelsScreen(
|
||||
onOpenChannel = { handle -> channelDetails = handle },
|
||||
|
|
@ -177,6 +178,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
||||
)
|
||||
Destination.Subscriptions ->
|
||||
SubscriptionRowsScreen(
|
||||
|
|
@ -184,6 +186,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
||||
)
|
||||
Destination.History ->
|
||||
VideoListScreen(
|
||||
|
|
@ -192,6 +195,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
onOpenDrawer = { drawerOpen = true },
|
||||
upToTopBar = { runCatching { searchFocus.requestFocus() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +240,7 @@ private fun Destination.icon(): ImageVector =
|
|||
private fun TopBar(
|
||||
onSearchClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
searchFocus: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -249,6 +254,7 @@ private fun TopBar(
|
|||
icon = Icons.Filled.Search,
|
||||
desc = "Search",
|
||||
onClick = onSearchClick,
|
||||
focusRequester = searchFocus,
|
||||
)
|
||||
// "Who is logged in" — account button: avatar circle + display name (SmartTube).
|
||||
val accountFocused = remember { mutableStateOf(false) }
|
||||
|
|
@ -261,6 +267,7 @@ private fun TopBar(
|
|||
else Modifier
|
||||
)
|
||||
.focusable()
|
||||
.dpadCenterActivatable(onAccountClick)
|
||||
.clickable(onClick = onAccountClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -317,6 +324,7 @@ private fun RoundTopButton(
|
|||
icon: ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
focusRequester: FocusRequester? = null,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
|
|
@ -330,7 +338,9 @@ private fun RoundTopButton(
|
|||
3.dp, Color.White, CircleShape
|
||||
) else Modifier
|
||||
)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.focusable()
|
||||
.dpadCenterActivatable(onClick)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
|
@ -364,7 +374,16 @@ private fun MenuDrawer(
|
|||
.width(240.dp)
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF111111))
|
||||
.padding(vertical = 24.dp),
|
||||
.padding(vertical = 24.dp)
|
||||
.onPreviewKeyEvent { ke ->
|
||||
// RIGHT from the drawer closes it; focus returns to the top bar.
|
||||
if (ke.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN &&
|
||||
ke.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
) {
|
||||
onDismiss()
|
||||
true
|
||||
} else false
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = "JTube TV",
|
||||
|
|
@ -401,12 +420,11 @@ private fun MenuDrawer(
|
|||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
// tap/click outside closes; focus LEFT from the drawer also closes
|
||||
// tap/click outside closes the drawer
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.focusable()
|
||||
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
|
||||
)
|
||||
}
|
||||
|
|
@ -439,6 +457,7 @@ private fun MenuDrawerItem(
|
|||
}
|
||||
)
|
||||
.focusable()
|
||||
.dpadCenterActivatable(onClick)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 24.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import net.jeena.jtubetv.ui.screens.list.dpadCenterActivatable
|
||||
|
||||
/**
|
||||
* Feature 0: ask for instance URL + credentials once. The password is used only for
|
||||
|
|
@ -164,9 +165,6 @@ fun SetupScreen(
|
|||
Text(message.value ?: localError.value!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(
|
||||
// Deliberately always focusable: on TV a permanently disabled button
|
||||
// is unreachable and leaves the user stuck without feedback (UX review).
|
||||
// Validation happens on press with an inline message instead.
|
||||
onClick = {
|
||||
when {
|
||||
url.value.isBlank() || username.value.isBlank() || password.value.isBlank() ->
|
||||
|
|
@ -178,7 +176,20 @@ fun SetupScreen(
|
|||
}
|
||||
},
|
||||
enabled = !busy.value,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.dpadCenterActivatable(
|
||||
onClick = {
|
||||
when {
|
||||
url.value.isBlank() || username.value.isBlank() || password.value.isBlank() ->
|
||||
localError.value = "All fields are required"
|
||||
else -> {
|
||||
localError.value = null
|
||||
onLogin(url.value, username.value, password.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
) {
|
||||
if (busy.value) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp))
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ fun SubscriptionRowsScreen(
|
|||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
upToTopBar: (() -> Unit)? = null,
|
||||
viewModel: SubscriptionRowsViewModel = viewModel(
|
||||
key = "subsrows$epoch",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
|
|
@ -157,6 +158,7 @@ fun SubscriptionRowsScreen(
|
|||
progressOf = { null },
|
||||
onOpenVideo = onOpenVideo,
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
upToTopBar = upToTopBar.takeIf { group == groups.firstOrNull() },
|
||||
) { rowIndex, _ ->
|
||||
if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||
viewModel.loadMore()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,8 @@ fun VideoCard(
|
|||
compact: Boolean = false,
|
||||
/** LEFT (only on the row's first card) can open the menu drawer. */
|
||||
onLeftAtStart: (() -> Unit)? = null,
|
||||
/** Explicit UP-target for the first content row (focus search misses the top bar). */
|
||||
onUpToTopBar: (() -> Unit)? = null,
|
||||
/** Notifies the row wrapper when this card gains focus (for eased scrolling). */
|
||||
onFocused: (() -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -67,17 +69,23 @@ fun VideoCard(
|
|||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.padding(6.dp)
|
||||
.dpadCenterActivatable(onClick)
|
||||
.onFocusChanged {
|
||||
cardFocused = it.isFocused
|
||||
if (it.isFocused) onFocused?.invoke()
|
||||
}
|
||||
.onPreviewKeyEvent { ke ->
|
||||
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
|
||||
ke.key == androidx.compose.ui.input.key.Key.DirectionLeft &&
|
||||
onLeftAtStart != null
|
||||
) {
|
||||
onLeftAtStart()
|
||||
true
|
||||
val direction = ke.key
|
||||
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown) {
|
||||
when {
|
||||
direction == androidx.compose.ui.input.key.Key.DirectionLeft && onLeftAtStart != null -> {
|
||||
onLeftAtStart(); true
|
||||
}
|
||||
direction == androidx.compose.ui.input.key.Key.DirectionUp && onUpToTopBar != null -> {
|
||||
onUpToTopBar(); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
},
|
||||
colors = CardDefaults.colors(
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
import net.jeena.jtubetv.R
|
||||
import net.jeena.jtubetv.data.api.VideoDto
|
||||
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
||||
import net.jeena.jtubetv.ui.screens.rowCardWidth
|
||||
import net.jeena.jtubetv.ui.list.ListSource
|
||||
import net.jeena.jtubetv.ui.list.VideoListItem
|
||||
import net.jeena.jtubetv.ui.list.VideoListViewModel
|
||||
|
|
@ -44,6 +45,7 @@ fun VideoListScreen(
|
|||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
upToTopBar: (() -> Unit)? = null,
|
||||
viewModel: VideoListViewModel = viewModel(
|
||||
key = "v$epoch:" + sourceKey(source),
|
||||
factory = VideoListViewModel.factory(source),
|
||||
|
|
@ -120,7 +122,8 @@ fun VideoListScreen(
|
|||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
// Same card size as the Home rows: ~4 fit per row (user request).
|
||||
columns = GridCells.Adaptive(rowCardWidth() - 12.dp),
|
||||
state = gridState,
|
||||
modifier = Modifier.fillMaxSize().padding(vertical = 8.dp),
|
||||
) {
|
||||
|
|
@ -136,7 +139,9 @@ fun VideoListScreen(
|
|||
video = video,
|
||||
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
||||
showPrivacy = source is ListSource.MyVideos,
|
||||
compact = true,
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
onUpToTopBar = upToTopBar?.takeIf { index < 4 },
|
||||
modifier = Modifier
|
||||
.focusRequester(fr),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,16 +7,43 @@
|
|||
<path
|
||||
android:fillColor="#101010"
|
||||
android:pathData="M0,0h320v180h-320z" />
|
||||
<path
|
||||
android:fillColor="#CE2B2B"
|
||||
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" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M60,62l26,20l-26,20z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M128,76h160v10h-160z" />
|
||||
<path
|
||||
android:fillColor="#66FFFFFF"
|
||||
android:pathData="M128,100h120v8h-120z" />
|
||||
<group
|
||||
android:scaleX="1.2"
|
||||
android:scaleY="1.2"
|
||||
android:translateX="106.9"
|
||||
android:translateY="34.9">
|
||||
<path
|
||||
android:fillColor="#F2690D"
|
||||
android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" />
|
||||
<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
|
||||
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>
|
||||
|
|
@ -5,13 +5,42 @@
|
|||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:translateX="30"
|
||||
android:translateY="30">
|
||||
android:scaleX="0.6"
|
||||
android:scaleY="0.6"
|
||||
android:translateX="27.5"
|
||||
android:translateY="26.5">
|
||||
<path
|
||||
android:fillColor="#CE2B2B"
|
||||
android:pathData="M4,4h40a4,4 0 0 1 4,4v40a4,4 0 0 1 -4,4h-40a4,4 0 0 1 -4,-4v-40a4,4 0 0 1 4,-4z" />
|
||||
android:fillColor="#F2690D"
|
||||
android:pathData="M60.2698 33.1237 c 0.2377 5.131 -7.2718 9.1005 -16.0739 9.6703 -8.8564 0.5733 -16.5815 -2.4078 -16.8896 -7.2214 -0.1502 -2.3457 1.4619 -4.9221 4.4079 -7.0758 2.9454 -2.1532 6.9675 -3.6913 11.2701 -4.1138 4.3242 -0.4245 8.5766 0.3156 11.9031 2.0468 3.3097 1.7224 5.2671 4.2043 5.3824 6.6939 z" />
|
||||
<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
|
||||
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>
|
||||
</vector>
|
||||
32
scripts/emulator-seed.sh
Executable file
32
scripts/emulator-seed.sh
Executable 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])"
|
||||
Loading…
Add table
Add a link
Reference in a new issue