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:
Jeena 2026-10-11 05:42:02 +00:00
parent 5ea1ca5124
commit b3b39976fe
7 changed files with 435 additions and 62 deletions

View file

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

View file

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

View file

@ -112,4 +112,7 @@ interface PeerTubeApi {
@GET("users/me")
suspend fun me(): UserDto
@GET("config")
suspend fun serverConfig(): ServerConfigDto
}

View file

@ -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()

View file

@ -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,43 +234,60 @@ 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
item {
RowHeader(
avatar = viewModel.user?.avatars?.maxByOrNull { it.width },
label = (viewModel.user?.username ?: "You") + " — your channels",
label = (viewModel.user?.displayName ?: viewModel.user?.username ?: "You") + " — your channels",
)
ChannelCardRow(
}
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 = {},
)
}
if (viewModel.followedChannels.isNotEmpty()) {
RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer)
ChannelCardRow(
viewModel.followedChannels,
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 ?: ""] },
)
}
}
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)
}
}

View file

@ -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) {
if (drawerOpen) {
if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish()
} else {
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,

View file

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