diff --git a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt index 64d35bb..5079c0d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/PeerTubeRepository.kt @@ -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 = runCatching { network.api.me() } + suspend fun serverConfig(): Result = runCatching { network.api.serverConfig() } + + /** Display name of a (possibly foreign) instance, via that instance's /config. */ + suspend fun instanceName(rawHost: String): Result = 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 = + 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 = diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt index 41cf173..b807ebc 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/Dtos.kt @@ -60,6 +60,14 @@ data class VideoListResponse( val data: List, ) +@Serializable +data class ChannelOwnerAccount( + val name: String, + val host: String? = null, + @SerialName("displayName") val displayName: String? = null, + val avatars: List = emptyList(), +) + @Serializable data class VideoChannelDto( val name: String, @@ -70,6 +78,7 @@ data class VideoChannelDto( val banners: List = 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, diff --git a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt index 76dd8e2..4c505ce 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/api/PeerTubeApi.kt @@ -112,4 +112,7 @@ interface PeerTubeApi { @GET("users/me") suspend fun me(): UserDto + + @GET("config") + suspend fun serverConfig(): ServerConfigDto } \ No newline at end of file diff --git a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt index 0b006ce..054da4d 100644 --- a/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt +++ b/app/src/main/java/net/jeena/jtubetv/data/auth/TokenStore.kt @@ -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() diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt index fa76b3a..707db29 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/ChannelsScreen.kt @@ -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, +) { + /** 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>(emptyList()) private set - var followedChannels by mutableStateOf>(emptyList()) + + /** One row per (local) user, sorted by total video count; empties last. */ + var localUserRows by mutableStateOf>(emptyList()) private set + + /** Channels from instances this server follows, grouped by their instance. */ + var followedRows by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(true) private set var error by mutableStateOf(null) private set + /** "name@host" -> videos + plays; filled in the background. */ + var stats by mutableStateOf>(emptyMap()) + private set + + /** host -> platform display name (e.g. "JTube" instead of tube.jeena.net). */ + var platformNames by mutableStateOf>(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() 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) = row.firstOrNull()?.ownerAccount?.displayName + private fun rowAvatar(row: List) = row.firstOrNull()?.ownerAccount?.avatars?.maxByOrNull { it.width } + private fun rowTotal(row: List) = 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, 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() } @@ -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, + ) + } } } } -} \ No newline at end of file +} + +@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) + } +} diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt index b2de95a..7e9927d 100644 --- a/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/HomeScreen.kt @@ -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, diff --git a/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt new file mode 100644 index 0000000..3a73e01 --- /dev/null +++ b/app/src/main/java/net/jeena/jtubetv/ui/screens/SubscriptionsScreen.kt @@ -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() { + + override fun onChunkLoaded(chunk: List) { + ImagePrefetcher.videoThumbnails(chunk) + } + + override suspend fun fetch(start: Long, count: Int): Result> = + repository.subscriptionVideos(start, count).map { it.data } +} + +private data class SubGroup( + val channel: VideoChannelSummary, + val startIndex: Int, + val videos: List, +) + +private fun buildGroups(items: List): List { + val groups = mutableListOf() + 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 create(modelClass: Class): 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() + } + } + } + } + } +} \ No newline at end of file