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 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> =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,42 +234,59 @@ 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()) {
|
viewModel.localUserRows.forEach { group ->
|
||||||
RowHeader(avatar = null, label = "Local channels", onOpenDrawer = onOpenDrawer)
|
item {
|
||||||
ChannelCardRow(
|
RowHeader(
|
||||||
viewModel.localChannels,
|
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 ?: ""] },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewModel.followedChannels.isNotEmpty()) {
|
viewModel.followedRows.forEach { group ->
|
||||||
RowHeader(avatar = null, label = "Followed instances", onOpenDrawer = onOpenDrawer)
|
item {
|
||||||
ChannelCardRow(
|
RowHeader(avatar = null, label = (group.label ?: "?") + " (" + group.videos + ")")
|
||||||
viewModel.followedChannels,
|
}
|
||||||
onOpenChannel,
|
item {
|
||||||
onOpenDrawer,
|
ChannelRow(
|
||||||
firstFocused = false,
|
group.rows,
|
||||||
onLeftAtStart = {},
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
(context as? android.app.Activity)?.finish()
|
if (now - lastBackAt < 2500) {
|
||||||
} else {
|
(context as? android.app.Activity)?.finish()
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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