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 kotlinx.serialization.json.Json
import net.jeena.jtubetv.data.api.HistoryElement 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.OAuthClient
import net.jeena.jtubetv.data.api.ServerConfigDto
import net.jeena.jtubetv.data.api.PeerTubeApi import net.jeena.jtubetv.data.api.PeerTubeApi
import net.jeena.jtubetv.data.api.TokenResponse import net.jeena.jtubetv.data.api.TokenResponse
import net.jeena.jtubetv.data.api.UserDto import net.jeena.jtubetv.data.api.UserDto
@ -86,6 +88,9 @@ class PeerTubeRepository(
.addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType()))
.build() .build()
.create(PeerTubeApi::class.java) .create(PeerTubeApi::class.java)
store.instanceName = try {
meApi.serverConfig().instance.name
} catch (_: Exception) { null }
val me = meApi.me() val me = meApi.me()
store.accountName = me.username store.accountName = me.username
store.userDisplayName = me.displayName ?: 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 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 ------------------------------------------------------- // ---- video lists -------------------------------------------------------
suspend fun localVideos(start: Long, count: Int, search: String? = null): Result<VideoListResponse> = 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>, 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 @Serializable
data class VideoChannelDto( data class VideoChannelDto(
val name: String, val name: String,
@ -70,6 +78,7 @@ data class VideoChannelDto(
val banners: List<Avatar> = emptyList(), val banners: List<Avatar> = emptyList(),
val videosCount: Long = 0, val videosCount: Long = 0,
val followersCount: Long = 0, val followersCount: Long = 0,
val ownerAccount: ChannelOwnerAccount? = null,
) )
@Serializable @Serializable
@ -134,6 +143,23 @@ data class VideoTokenFiles(
val expires: String? = null, 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 @Serializable
data class UserDto( data class UserDto(
val username: String, val username: String,

View file

@ -112,4 +112,7 @@ interface PeerTubeApi {
@GET("users/me") @GET("users/me")
suspend fun me(): UserDto 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) get() = prefs.getString("userDisplayName", null)
set(value) = prefs.edit().putString("userDisplayName", value).apply() 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? var userAvatarUrl: String?
get() = prefs.getString("userAvatarUrl", null) get() = prefs.getString("userAvatarUrl", null)
set(value) = prefs.edit().putString("userAvatarUrl", value).apply() 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyRow 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.lazy.itemsIndexed
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -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.ContainerShape
import net.jeena.jtubetv.ui.screens.list.UnfocusedCardBg 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: * Channels as SmartTube-style rows:
* 1. "Your channels" — header with account avatar + username, then own channel cards. * 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 private set
var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList()) var localChannels by mutableStateOf<List<VideoChannelDto>>(emptyList())
private set 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 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) var loading by mutableStateOf(true)
private set private set
var error by mutableStateOf<String?>(null) var error by mutableStateOf<String?>(null)
private set 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 private val repo: PeerTubeRepository = AppGraph.repository
fun load(force: Boolean = false) { fun load(force: Boolean = false) {
@ -85,13 +115,11 @@ class ChannelsViewModel(private val store: TokenStore) : ViewModel() {
error = null error = null
viewModelScope.launch { viewModelScope.launch {
try { try {
// row header (avatar + real username)
user = repo.me().getOrNull() user = repo.me().getOrNull()
userChannels = user?.let { u -> userChannels = user?.let { u ->
repo.accountChannels(u.username).getOrNull() ?: emptyList() repo.accountChannels(u.username).getOrNull() ?: emptyList()
} ?: emptyList() } ?: emptyList()
// enumerate the whole channel list, split by host client-side
val all = mutableListOf<VideoChannelDto>() val all = mutableListOf<VideoChannelDto>()
var start = 0L var start = 0L
var total: Long? = null 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 } all += page.first.filter { c -> (c.name to c.host) !in known }
start += 100 start += 100
} }
val host = store.baseUrl?.substringAfter("://")?.substringBefore('/') val host = store.baseUrl?.substringAfter("://")?.substringBefore('/')
val ownNames = userChannels.map { c -> c.name }.toSet() val ownNames = userChannels.map { c -> c.name }.toSet()
val mine = all.filter { it.host == host && it.name in ownNames } 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 } } val rest = all.filterNot { c -> mine.any { it.name == c.name && it.host == c.host } }
userChannels = (userChannels + mine).distinctBy { it.name to it.host } userChannels = (userChannels + mine).distinctBy { it.name to it.host }
localChannels = rest.filter { it.host == host }.sortedBy { it.displayName } localChannels = rest.filter { it.host == host }
followedChannels = rest.filter { it.host != host }.sortedBy { it.displayName }
net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all) net.jeena.jtubetv.ui.list.ImagePrefetcher.channelArt(all)
// platform display names (local cached in prefs; foreign lazy)
platformNames = mapOf(
(host ?: "") to (store.instanceName ?: host ?: ""),
)
val foreignHosts = rest.map { it.host ?: "?" }.toSet() - (host ?: "")
for (fh in foreignHosts) {
val name = repo.instanceName(fh).getOrNull() ?: continue
platformNames = platformNames + (fh to name)
}
// video + play counts per channel, one tiny listing call per channel
for (channel in (userChannels + localChannels)) {
val key = channel.name + "@" + channel.host
val cs = repo.channelStats(key).getOrNull() ?: continue
stats = stats + (key to cs)
}
// group LOCAL channels by owner (user): row per user, videos desc,
// channels with zero videos collected into a single last row.
val locals = rest.filter { it.host == host }
val withVideos = locals.filter { it.videosCount > 0 }
val empty = locals.filter { it.videosCount <= 0 }
val byOwner = withVideos.groupBy { ownerKey(it) }
localUserRows =
byOwner.values
.map { row -> UserGroup(
label = rowLabel(row),
avatar = rowAvatar(row),
videos = rowTotal(row),
rows = row.sortedByDescending { it.videosCount },
) }
.filter { it.label != null }
.sortedByDescending { it.rowWeight() }
if (empty.isNotEmpty()) {
localUserRows += UserGroup(label = "No videos yet", avatar = null, videos = 0, rows = empty)
}
followedRows = rest.filter { it.host != host }
.groupBy { it.host ?: "?" }
.values
.map { row -> UserGroup(label = rowLabel(row), avatar = null, videos = rowTotal(row), rows = row.sortedByDescending { it.videosCount }) }
.sortedByDescending { it.rowWeight() }
} catch (e: Exception) { } catch (e: Exception) {
error = e.message ?: "Could not load channels" error = e.message ?: "Could not load channels"
} finally { } finally {
@ -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 { companion object {
val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { val factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -158,43 +234,60 @@ fun ChannelsScreen(
val lastOpened = remember { mutableIntStateOf(-1) } val lastOpened = remember { mutableIntStateOf(-1) }
var focusedRow by remember { mutableIntStateOf(0) } var focusedRow by remember { mutableIntStateOf(0) }
Column(Modifier.fillMaxSize()) { androidx.compose.foundation.lazy.LazyColumn(Modifier.fillMaxSize()) {
if (viewModel.userChannels.isNotEmpty()) { if (viewModel.userChannels.isNotEmpty()) {
// row 1: the user's own channels; header = account icon + username item {
RowHeader( RowHeader(
avatar = viewModel.user?.avatars?.maxByOrNull { it.width }, 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, viewModel.userChannels,
onOpenChannel, onOpenChannel,
onOpenDrawer, onOpenDrawer,
firstFocused = true, 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()) { viewModel.localUserRows.forEach { group ->
RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer) item {
ChannelCardRow( RowHeader(
viewModel.followedChannels, avatar = group.avatar,
onOpenChannel, label = (group.label ?: "?") + " (" + group.videos + ")",
onOpenDrawer,
firstFocused = false,
onLeftAtStart = {},
) )
} }
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. */ /** Horizontal row of channel cards; eases newly focused cards into view. */
@Composable @Composable
private fun ChannelCardRow( private fun ChannelRow(
channels: List<VideoChannelDto>, channels: List<VideoChannelDto>,
onOpenChannel: (String) -> Unit, onOpenChannel: (String) -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
firstFocused: Boolean, firstFocused: Boolean,
onLeftAtStart: () -> Unit, statsProvider: (VideoChannelDto) -> net.jeena.jtubetv.data.api.ChannelStats?,
platformProvider: (VideoChannelDto) -> String?,
) { ) {
val rowState = androidx.compose.foundation.lazy.rememberLazyListState() val rowState = androidx.compose.foundation.lazy.rememberLazyListState()
val focusRequesters = remember { HashMap<Int, FocusRequester>() } val focusRequesters = remember { HashMap<Int, FocusRequester>() }
@ -268,6 +362,8 @@ private fun ChannelCardRow(
ChannelCard( ChannelCard(
channel = channel, channel = channel,
onClick = { onOpenChannel("${channel.name}@${channel.host}") }, onClick = { onOpenChannel("${channel.name}@${channel.host}") },
stats = statsProvider(channel),
platformName = platformProvider(channel),
onLeftAtStart = if (index == 0) onOpenDrawer else null, onLeftAtStart = if (index == 0) onOpenDrawer else null,
modifier = Modifier.focusRequester(fr), 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 @Composable
fun ChannelCard( fun ChannelCard(
channel: VideoChannelDto, channel: VideoChannelDto,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
stats: net.jeena.jtubetv.data.api.ChannelStats? = null,
platformName: String? = null,
onLeftAtStart: (() -> Unit)? = null, onLeftAtStart: (() -> Unit)? = null,
) { ) {
var cardFocused by remember { mutableStateOf(false) } var cardFocused by remember { mutableStateOf(false) }
@ -320,21 +421,23 @@ fun ChannelCard(
val banner = channel.banners.maxByOrNull { it.width } val banner = channel.banners.maxByOrNull { it.width }
if (banner != null) { if (banner != null) {
AsyncImage( 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)) .data(absolute(banner.fileUrl ?: banner.path))
.size(480) .size(480)
.build(), .build(),
contentDescription = channel.displayName, contentDescription = channel.displayName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
.fillMaxWidth()
.aspectRatio(16f / 9f),
) )
} }
val avatar = channel.avatars.maxByOrNull { it.width } val avatar = channel.avatars.maxByOrNull { it.width }
if (avatar != null) { if (avatar != null) {
AsyncImage( 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)) .data(absolute(avatar.fileUrl ?: avatar.path))
.size(80) .size(80)
.build(), .build(),
@ -343,7 +446,7 @@ fun ChannelCard(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(8.dp) .padding(8.dp)
.size(36.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF333333)), .background(Color(0xFF333333)),
) )
@ -357,19 +460,45 @@ fun ChannelCard(
text = channel.displayName.ifBlank { channel.name }, text = channel.displayName.ifBlank { channel.name },
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
minLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
color = if (cardFocused) Color(0xFF101010) else Color.White, color = if (cardFocused) Color(0xFF101010) else Color.White,
) )
Text( Text(
text = channel.host + " · " + channel.videosCount + " videos · " + text = (channel.ownerAccount?.displayName ?: "") +
channel.followersCount + " followers", (if (platformName != null) " - " + platformName else ""),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A), color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp),
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++ if (playingVideo == null && restoreToken >= 1) restoreToken++
} }
// BACK order: player (own BackHandler) → drawer → channel drilldown → exit guard. // BACK order: player (own BackHandler) → drawer close → channel drilldown →
BackHandler(enabled = playingVideo == null && channelDetails == null && !drawerOpen) { // BACK (on content) opens the drawer; a double-BACK with the drawer open exits.
BackHandler(enabled = playingVideo == null && channelDetails == null) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastBackAt < 2000) { if (drawerOpen) {
if (now - lastBackAt < 2500) {
(context as? android.app.Activity)?.finish() (context as? android.app.Activity)?.finish()
} else { return@BackHandler
}
lastBackAt = now lastBackAt = now
android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(context, "Press BACK again to exit", android.widget.Toast.LENGTH_SHORT).show()
} else {
lastBackAt = now
drawerOpen = true
} }
} }
BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) { BackHandler(enabled = playingVideo == null && channelDetails != null && !drawerOpen) {
channelDetails = null channelDetails = null
} }
BackHandler(enabled = drawerOpen && playingVideo == 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 drawerOpen = false
} }
@ -167,8 +179,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
onOpenDrawer = { drawerOpen = true }, onOpenDrawer = { drawerOpen = true },
) )
Destination.Subscriptions -> Destination.Subscriptions ->
VideoListScreen( SubscriptionRowsScreen(
source = ListSource.Subscriptions,
onOpenVideo = { playingVideo = it.shortUUID }, onOpenVideo = { playingVideo = it.shortUUID },
restoreToken = restoreToken, restoreToken = restoreToken,
epoch = epoch, epoch = epoch,
@ -428,17 +439,6 @@ private fun MenuDrawerItem(
} }
) )
.focusable() .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) .clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp), .padding(horizontal = 24.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

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