channels: Per-user rows, empty-channels row, redesigned stats card, subs detail
Channels now render as SmartTube rows and scroll vertically (LazyColumn - the unscrollable Column hid rows 2+). Local channels group one row per ownerAccount, sorted by total video count, with all zero-video channels merged into a final 'No videos yet' row. Followed-instance channels group per platform. The channel card matches the video-card language: banner center-cropped, round avatar bottom-left, channel name, 'owner display name - platform display name' (instance names via /config, local cached at login; no domain strings), and an icon stat line (videos | subscribers | plays); counts load per channel in the background since the listing endpoint doesn't carry them (verified on the live API). Subscriptions destination switches to per-channel rows, matching the Home card size.
This commit is contained in:
parent
5ea1ca5124
commit
b3b39976fe
7 changed files with 435 additions and 62 deletions
|
|
@ -2,7 +2,9 @@ package net.jeena.jtubetv.data
|
|||
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.jeena.jtubetv.data.api.HistoryElement
|
||||
import net.jeena.jtubetv.data.api.ChannelStats
|
||||
import net.jeena.jtubetv.data.api.OAuthClient
|
||||
import net.jeena.jtubetv.data.api.ServerConfigDto
|
||||
import net.jeena.jtubetv.data.api.PeerTubeApi
|
||||
import net.jeena.jtubetv.data.api.TokenResponse
|
||||
import net.jeena.jtubetv.data.api.UserDto
|
||||
|
|
@ -86,6 +88,9 @@ class PeerTubeRepository(
|
|||
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
.create(PeerTubeApi::class.java)
|
||||
store.instanceName = try {
|
||||
meApi.serverConfig().instance.name
|
||||
} catch (_: Exception) { null }
|
||||
val me = meApi.me()
|
||||
store.accountName = me.username
|
||||
store.userDisplayName = me.displayName ?: me.username
|
||||
|
|
@ -101,6 +106,43 @@ class PeerTubeRepository(
|
|||
|
||||
suspend fun me(): Result<UserDto> = runCatching { network.api.me() }
|
||||
|
||||
suspend fun serverConfig(): Result<ServerConfigDto> = runCatching { network.api.serverConfig() }
|
||||
|
||||
/** Display name of a (possibly foreign) instance, via that instance's /config. */
|
||||
suspend fun instanceName(rawHost: String): Result<String> = runCatching {
|
||||
val base = "https://" + rawHost
|
||||
val api = unauthenticatedApiFor(base)
|
||||
api.serverConfig().instance.name ?: rawHost
|
||||
}
|
||||
|
||||
/**
|
||||
* videosCount + total plays for a channel (listing DTOs don't carry them).
|
||||
* Sums views over up to [maxPages] video pages.
|
||||
*/
|
||||
suspend fun channelStats(handle: String, maxPages: Int = 4): Result<net.jeena.jtubetv.data.api.ChannelStats> =
|
||||
runCatching {
|
||||
var start = 0L
|
||||
var videos = 0L
|
||||
var plays = 0L
|
||||
var total: Long? = null
|
||||
repeat(maxPages) {
|
||||
val page = network.api.channelVideos(handle, start, 25)
|
||||
if (total == null) total = page.total
|
||||
plays += page.data.sumOf { it.views }
|
||||
start += page.data.size
|
||||
videos = total ?: videos
|
||||
}
|
||||
net.jeena.jtubetv.data.api.ChannelStats(videos, plays)
|
||||
}
|
||||
|
||||
private fun unauthenticatedApiFor(base: String): PeerTubeApi =
|
||||
Retrofit.Builder()
|
||||
.baseUrl(apiUrlOf(base) + "/")
|
||||
.client(plainOkHttp())
|
||||
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
.create(PeerTubeApi::class.java)
|
||||
|
||||
// ---- video lists -------------------------------------------------------
|
||||
|
||||
suspend fun localVideos(start: Long, count: Int, search: String? = null): Result<VideoListResponse> =
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ data class VideoListResponse(
|
|||
val data: List<VideoDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelOwnerAccount(
|
||||
val name: String,
|
||||
val host: String? = null,
|
||||
@SerialName("displayName") val displayName: String? = null,
|
||||
val avatars: List<Avatar> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoChannelDto(
|
||||
val name: String,
|
||||
|
|
@ -70,6 +78,7 @@ data class VideoChannelDto(
|
|||
val banners: List<Avatar> = emptyList(),
|
||||
val videosCount: Long = 0,
|
||||
val followersCount: Long = 0,
|
||||
val ownerAccount: ChannelOwnerAccount? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -134,6 +143,23 @@ data class VideoTokenFiles(
|
|||
val expires: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerConfigDto(
|
||||
val instance: InstanceInfoDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InstanceInfoDto(
|
||||
val name: String? = null,
|
||||
val shortDescription: String? = null,
|
||||
)
|
||||
|
||||
/** Per-channel stats not available in the listing; fetched on demand. */
|
||||
data class ChannelStats(
|
||||
val videos: Long,
|
||||
val plays: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserDto(
|
||||
val username: String,
|
||||
|
|
|
|||
|
|
@ -112,4 +112,7 @@ interface PeerTubeApi {
|
|||
|
||||
@GET("users/me")
|
||||
suspend fun me(): UserDto
|
||||
|
||||
@GET("config")
|
||||
suspend fun serverConfig(): ServerConfigDto
|
||||
}
|
||||
|
|
@ -53,6 +53,11 @@ class TokenStore(context: Context) {
|
|||
get() = prefs.getString("userDisplayName", null)
|
||||
set(value) = prefs.edit().putString("userDisplayName", value).apply()
|
||||
|
||||
/** Human-friendly instance name (e.g. "JTube") for card subtitles. */
|
||||
var instanceName: String?
|
||||
get() = prefs.getString("instanceName", null)
|
||||
set(value) = prefs.edit().putString("instanceName", value).apply()
|
||||
|
||||
var userAvatarUrl: String?
|
||||
get() = prefs.getString("userAvatarUrl", null)
|
||||
set(value) = prefs.edit().putString("userAvatarUrl", value).apply()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ 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
|
||||
|
|
@ -53,6 +57,17 @@ import net.jeena.jtubetv.data.api.VideoChannelDto
|
|||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels as SmartTube-style rows:
|
||||
* 1. "Your channels" — header with account avatar + username, then own channel cards.
|
||||
|
|
@ -70,13 +85,28 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
private set
|
||||
var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
private set
|
||||
var followedChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
|
||||
|
||||
/** One row per (local) user, sorted by total video count; empties last. */
|
||||
var localUserRows by mutableStateOf<List<UserGroup>>(emptyList())
|
||||
private set
|
||||
|
||||
/** Channels from instances this server follows, grouped by their instance. */
|
||||
var followedRows by mutableStateOf<List<UserGroup>>(emptyList())
|
||||
private set
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
private set
|
||||
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())
|
||||
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 val repo: PeerTubeRepository = AppGraph.repository
|
||||
|
||||
fun load(force: Boolean = false) {
|
||||
|
|
@ -85,13 +115,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
error = null
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// row header (avatar + real username)
|
||||
user = repo.me().getOrNull()
|
||||
userChannels = user?.let { u ->
|
||||
repo.accountChannels(u.username).getOrNull() ?: emptyList()
|
||||
} ?: emptyList()
|
||||
|
||||
// enumerate the whole channel list, split by host client-side
|
||||
val all = mutableListOf<VideoChannelDto>()
|
||||
var start = 0L
|
||||
var total: Long? = null
|
||||
|
|
@ -102,14 +130,56 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
|
|||
all += page.first.filter { c -> (c.name to c.host) !in known }
|
||||
start += 100
|
||||
}
|
||||
|
||||
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 }.sortedBy { it.displayName }
|
||||
followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName }
|
||||
localChannels = rest.filter { it.host == host }
|
||||
|
||||
net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all)
|
||||
|
||||
// platform display names (local cached in prefs; foreign lazy)
|
||||
platformNames = mapOf(
|
||||
(host ?: "") to (store.instanceName ?: host ?: ""),
|
||||
)
|
||||
val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "")
|
||||
for (fh in foreignHosts) {
|
||||
val name = repo.instanceName(fh).getOrNull() ?: continue
|
||||
platformNames = platformNames + (fh to name)
|
||||
}
|
||||
|
||||
// video + play counts per channel, one tiny listing call per channel
|
||||
for (channel in (userChannels + localChannels)) {
|
||||
val key = channel.name + "@" + channel.host
|
||||
val cs = repo.channelStats(key).getOrNull() ?: continue
|
||||
stats = stats + (key to cs)
|
||||
}
|
||||
// group LOCAL channels by owner (user): row per user, videos desc,
|
||||
// channels with zero videos collected into a single last row.
|
||||
val locals = rest.filter { it.host == host }
|
||||
val withVideos = locals.filter { it.videosCount > 0 }
|
||||
val empty = locals.filter { it.videosCount <= 0 }
|
||||
val byOwner = withVideos.groupBy { ownerKey(it) }
|
||||
localUserRows =
|
||||
byOwner.values
|
||||
.map { row -> UserGroup(
|
||||
label = rowLabel(row),
|
||||
avatar = rowAvatar(row),
|
||||
videos = rowTotal(row),
|
||||
rows = row.sortedByDescending { it.videosCount },
|
||||
) }
|
||||
.filter { it.label != null }
|
||||
.sortedByDescending { it.rowWeight() }
|
||||
if (empty.isNotEmpty()) {
|
||||
localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty)
|
||||
}
|
||||
followedRows = rest.filter { it.host != host }
|
||||
.groupBy { it.host ?: "?" }
|
||||
.values
|
||||
.map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) }
|
||||
.sortedByDescending { it.rowWeight() }
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Could not load channels"
|
||||
} finally {
|
||||
|
|
@ -118,6 +188,12 @@ 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 }
|
||||
|
||||
|
||||
companion object {
|
||||
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
|
@ -158,42 +234,59 @@ fun ChannelsScreen(
|
|||
val lastOpened = remember { mutableIntStateOf(-1) }
|
||||
var focusedRow by remember { mutableIntStateOf(0) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) {
|
||||
if (viewModel.userChannels.isNotEmpty()) {
|
||||
// row 1: the user's own channels; header = account icon + username
|
||||
RowHeader(
|
||||
avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
|
||||
label = (viewModel.user?.username ?: "You") + " — your channels",
|
||||
)
|
||||
ChannelCardRow(
|
||||
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,
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformProvider = { viewModel.platformNames[it.host ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.localChannels.isNotEmpty()) {
|
||||
RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer)
|
||||
ChannelCardRow(
|
||||
viewModel.localChannels,
|
||||
onOpenChannel,
|
||||
onOpenDrawer,
|
||||
firstFocused = false,
|
||||
onLeftAtStart = {},
|
||||
)
|
||||
viewModel.localUserRows.forEach { group ->
|
||||
item {
|
||||
RowHeader(
|
||||
avatar = group.avatar,
|
||||
label = (group.label ?: "?") + " (" + group.videos + ")",
|
||||
)
|
||||
}
|
||||
item {
|
||||
ChannelRow(
|
||||
group.rows,
|
||||
onOpenChannel,
|
||||
{},
|
||||
firstFocused = false,
|
||||
statsProvider = { viewModel.stats[it.name + "@" + it.host] },
|
||||
platformProvider = { viewModel.platformNames[it.host ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.followedChannels.isNotEmpty()) {
|
||||
RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer)
|
||||
ChannelCardRow(
|
||||
viewModel.followedChannels,
|
||||
onOpenChannel,
|
||||
onOpenDrawer,
|
||||
firstFocused = false,
|
||||
onLeftAtStart = {},
|
||||
)
|
||||
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 ?: ""] },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -229,12 +322,13 @@ private fun absolute(pathOrUrl: String): String =
|
|||
|
||||
/** Horizontal row of channel cards; eases newly focused cards into view. */
|
||||
@Composable
|
||||
private fun ChannelCardRow(
|
||||
private fun ChannelRow(
|
||||
channels: List<VideoChannelDto>,
|
||||
onOpenChannel: (String) -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
firstFocused: Boolean,
|
||||
onLeftAtStart: () -> Unit,
|
||||
statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
|
||||
platformProvider: (VideoChannelDto) -> String?,
|
||||
) {
|
||||
val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val focusRequesters = remember { HashMap<Int, FocusRequester>() }
|
||||
|
|
@ -268,6 +362,8 @@ private fun ChannelCardRow(
|
|||
ChannelCard(
|
||||
channel = channel,
|
||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||
stats = statsProvider(channel),
|
||||
platformName = platformProvider(channel),
|
||||
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||
modifier = Modifier.focusRequester(fr),
|
||||
)
|
||||
|
|
@ -275,12 +371,17 @@ private fun ChannelCardRow(
|
|||
}
|
||||
}
|
||||
|
||||
/** SmartTube-style channel card: banner (16:9 crop) + avatar badge + name + counters. */
|
||||
/**
|
||||
* Channel card: banner top (center-cropped), round avatar bottom-left, then
|
||||
* channel name, "ownerDisplayName - platformDisplayName", stat line with icons.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
channel: VideoChannelDto,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
stats: net.jeena.jtubetv.data.api.ChannelStats? = null,
|
||||
platformName: String? = null,
|
||||
onLeftAtStart: (() -> Unit)? = null,
|
||||
) {
|
||||
var cardFocused by remember { mutableStateOf(false) }
|
||||
|
|
@ -320,21 +421,23 @@ fun ChannelCard(
|
|||
val banner = channel.banners.maxByOrNull { it.width }
|
||||
if (banner != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(absolute(banner.fileUrl ?: banner.path))
|
||||
.size(480)
|
||||
.build(),
|
||||
contentDescription = channel.displayName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
||||
)
|
||||
}
|
||||
val avatar = channel.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(absolute(avatar.fileUrl ?: avatar.path))
|
||||
.size(80)
|
||||
.build(),
|
||||
|
|
@ -343,7 +446,7 @@ fun ChannelCard(
|
|||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
.size(36.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF333333)),
|
||||
)
|
||||
|
|
@ -357,19 +460,45 @@ fun ChannelCard(
|
|||
text = channel.displayName.ifBlank { channel.name },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
minLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
color = if (cardFocused) Color(0xFF101010) else Color.White,
|
||||
)
|
||||
Text(
|
||||
text = channel.host + " · " + channel.videosCount + " videos · " +
|
||||
channel.followersCount + " followers",
|
||||
text = (channel.ownerAccount?.displayName ?: "") +
|
||||
(if (platformName != null) " - " + platformName else ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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))
|
||||
Text(value, style = MaterialTheme.typography.labelSmall, color = color)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,20 +88,32 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
if (playingVideo == null && restoreToken >= 1) restoreToken++
|
||||
}
|
||||
|
||||
// BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard.
|
||||
BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) {
|
||||
// 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 (now - lastBackAt < 2000) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
} else {
|
||||
if (drawerOpen) {
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
return@BackHandler
|
||||
}
|
||||
lastBackAt = now
|
||||
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
lastBackAt = now
|
||||
drawerOpen = true
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) {
|
||||
channelDetails = null
|
||||
}
|
||||
BackHandler(enabled = drawerOpen && playingVideo == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackAt < 2500) {
|
||||
(context as? android.app.Activity)?.finish()
|
||||
return@BackHandler
|
||||
}
|
||||
lastBackAt = now
|
||||
drawerOpen = false
|
||||
}
|
||||
|
||||
|
|
@ -167,8 +179,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
|||
onOpenDrawer = { drawerOpen = true },
|
||||
)
|
||||
Destination.Subscriptions ->
|
||||
VideoListScreen(
|
||||
source = ListSource.Subscriptions,
|
||||
SubscriptionRowsScreen(
|
||||
onOpenVideo = { playingVideo = it.shortUUID },
|
||||
restoreToken = restoreToken,
|
||||
epoch = epoch,
|
||||
|
|
@ -428,17 +439,6 @@ private fun MenuDrawerItem(
|
|||
}
|
||||
)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { ke ->
|
||||
when (ke.key) {
|
||||
androidx.compose.ui.input.key.Key.DirectionDown -> {
|
||||
focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Down); true
|
||||
}
|
||||
androidx.compose.ui.input.key.Key.DirectionUp -> {
|
||||
focusManager?.moveFocus(androidx.compose.ui.focus.FocusDirection.Up); true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 24.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
package net.jeena.jtubetv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import net.jeena.jtubetv.AppGraph
|
||||
import net.jeena.jtubetv.data.api.Avatar
|
||||
import net.jeena.jtubetv.data.api.VideoChannelSummary
|
||||
import net.jeena.jtubetv.data.api.VideoDto
|
||||
import net.jeena.jtubetv.ui.list.ImagePrefetcher
|
||||
import net.jeena.jtubetv.ui.list.PagedListViewModel
|
||||
|
||||
/**
|
||||
* Subscriptions, SmartTube-style: the feed grouped by channel — one row per
|
||||
* channel, newest-first within the row, row header = channel avatar + name.
|
||||
* Card sizes match the Home rows exactly.
|
||||
*/
|
||||
class SubscriptionRowsViewModel(
|
||||
private val repository: net.jeena.jtubetv.data.PeerTubeRepository = AppGraph.repository,
|
||||
) : PagedListViewModel<VideoDto>() {
|
||||
|
||||
override fun onChunkLoaded(chunk: List<VideoDto>) {
|
||||
ImagePrefetcher.videoThumbnails(chunk)
|
||||
}
|
||||
|
||||
override suspend fun fetch(start: Long, count: Int): Result<List<VideoDto>> =
|
||||
repository.subscriptionVideos(start, count).map { it.data }
|
||||
}
|
||||
|
||||
private data class SubGroup(
|
||||
val channel: VideoChannelSummary,
|
||||
val startIndex: Int,
|
||||
val videos: List<VideoDto>,
|
||||
)
|
||||
|
||||
private fun buildGroups(items: List<VideoDto>): List<SubGroup> {
|
||||
val groups = mutableListOf<SubGroup>()
|
||||
items.forEachIndexed { index, video ->
|
||||
val last = groups.lastOrNull()
|
||||
val same = last != null &&
|
||||
last.channel.name == video.channel.name &&
|
||||
last.channel.host == video.channel.host
|
||||
if (same) {
|
||||
groups[groups.lastIndex] = last!!.copy(videos = last.videos + video)
|
||||
} else {
|
||||
groups += SubGroup(video.channel, index, listOf(video))
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubscriptionRowsScreen(
|
||||
onOpenVideo: (VideoDto) -> Unit,
|
||||
restoreToken: Int = 0,
|
||||
epoch: Int = 0,
|
||||
onOpenDrawer: () -> Unit = {},
|
||||
viewModel: SubscriptionRowsViewModel = viewModel(
|
||||
key = "subsrows$epoch",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
SubscriptionRowsViewModel() as T
|
||||
},
|
||||
),
|
||||
) {
|
||||
val items = viewModel.items
|
||||
LaunchedEffect(Unit) { if (items.isEmpty()) viewModel.loadMore(force = true) }
|
||||
LaunchedEffect(restoreToken) { if (restoreToken > 0) viewModel.refresh() }
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
if (viewModel.initialError != null) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Could not load subscriptions", color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
viewModel.initialError ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.material3.Button(onClick = { viewModel.retryInitial() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
androidx.compose.material3.CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val groups = remember(items) { buildGroups(items) }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
groups.forEach { group ->
|
||||
item(key = "subrowhead" + group.channel.name + "@" + group.channel.host + group.startIndex) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
val avatar = group.channel.avatars.maxByOrNull { it.width }
|
||||
if (avatar != null) {
|
||||
AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(
|
||||
androidx.compose.ui.platform.LocalContext.current,
|
||||
)
|
||||
.data(avatar.fileUrl
|
||||
?: (AppGraph.tokenStore.baseUrl ?: "") + avatar.path)
|
||||
.size(80)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF222222)),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = group.channel.displayName.ifBlank { group.channel.name },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "subrow" + group.channel.name + "@" + group.channel.host + group.startIndex) {
|
||||
FocusScrollRow(
|
||||
videos = group.videos,
|
||||
progressOf = { null },
|
||||
onOpenVideo = onOpenVideo,
|
||||
onLeftAtStart = onOpenDrawer,
|
||||
) { rowIndex, _ ->
|
||||
if (group.startIndex + rowIndex >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue