Straw phase J: tappable uploader → channel browse
New Screen.Channel(channelUrl, name). ChannelViewModel calls NewPipeExtractor's ChannelInfo.getInfo() + first ChannelTab (Videos) for the list of streams. ChannelScreen renders banner + circular avatar + subscriber count + LazyColumn of recent videos. Wiring: - SearchViewModel.StreamItem now carries uploaderUrl from StreamInfoItem.uploaderUrl. - VideoDetailViewModel.VideoDetail likewise. - VideoDetailScreen: uploader name is now a clickable Text in primary color when uploaderUrl is non-null. Tap → Screen.Channel. - StrawActivity routes Screen.Channel to ChannelScreen. Smoke test: tapping "jawed" on the Me-at-the-zoo detail screen opens the jawed channel — banner, avatar, 6.1M subscribers, the one video he ever uploaded (this still cracks me up). Day-4: tappable uploader in search row, channel tabs (Playlists, Shorts), subscription toggle wired to a Subscriptions store.
This commit is contained in:
parent
6f5e1ed199
commit
06e6ec64e3
7 changed files with 275 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ sealed interface Screen {
|
|||
data object Settings : Screen
|
||||
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
||||
data class Player(val streamUrl: String, val title: String) : Screen
|
||||
data class Channel(val channelUrl: String, val name: String) : Screen
|
||||
}
|
||||
|
||||
class Navigator(initial: Screen) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material3.darkColorScheme
|
|||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.sulkta.straw.feature.channel.ChannelScreen
|
||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
||||
import com.sulkta.straw.feature.player.PlayerScreen
|
||||
import com.sulkta.straw.feature.search.SearchScreen
|
||||
|
|
@ -75,6 +76,16 @@ class StrawActivity : ComponentActivity() {
|
|||
onPlay = {
|
||||
nav.push(Screen.Player(s.streamUrl, s.title))
|
||||
},
|
||||
onOpenChannel = { url, name ->
|
||||
nav.push(Screen.Channel(url, name))
|
||||
},
|
||||
)
|
||||
is Screen.Channel -> ChannelScreen(
|
||||
channelUrl = s.channelUrl,
|
||||
initialName = s.name,
|
||||
onOpenVideo = { url, title ->
|
||||
nav.push(Screen.VideoDetail(url, title))
|
||||
},
|
||||
)
|
||||
is Screen.Player -> PlayerScreen(
|
||||
streamUrl = s.streamUrl,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.channel
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
|
||||
@Composable
|
||||
fun ChannelScreen(
|
||||
channelUrl: String,
|
||||
initialName: String,
|
||||
onOpenVideo: (url: String, title: String) -> Unit,
|
||||
vm: ChannelViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
||||
|
||||
when {
|
||||
state.loading -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
state.error != null -> Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
state.banner?.let { b ->
|
||||
AsyncImage(
|
||||
model = b,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(6f / 1f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = state.avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = state.name.ifBlank { initialName },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (state.subscriberCount > 0) {
|
||||
Text(
|
||||
text = "${formatCount(state.subscriberCount)} subscribers",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
items(state.videos) { item ->
|
||||
ChannelVideoRow(item) { onOpenVideo(item.url, item.title) }
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelVideoRow(item: StreamItem, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.height(80.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = buildString {
|
||||
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
|
||||
if (item.durationSeconds > 0) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(formatDuration(item.durationSeconds))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(sec: Long): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
val s = sec % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
private fun formatCount(n: Long): String = when {
|
||||
n >= 1_000_000_000 -> "%.1fB".format(n / 1_000_000_000.0)
|
||||
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
|
||||
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
|
||||
else -> "$n"
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.feature.channel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sulkta.straw.feature.search.StreamItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class ChannelUiState(
|
||||
val loading: Boolean = true,
|
||||
val name: String = "",
|
||||
val subscriberCount: Long = -1,
|
||||
val banner: String? = null,
|
||||
val avatar: String? = null,
|
||||
val videos: List<StreamItem> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ChannelViewModel : ViewModel() {
|
||||
private val _ui = MutableStateFlow(ChannelUiState())
|
||||
val ui: StateFlow<ChannelUiState> = _ui.asStateFlow()
|
||||
|
||||
fun load(channelUrl: String) {
|
||||
_ui.value = ChannelUiState(loading = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
ChannelInfo.getInfo(service, channelUrl)
|
||||
}
|
||||
val firstTab = info.tabs.firstOrNull()
|
||||
val videos: List<StreamItem> = if (firstTab != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ChannelTabInfo.getInfo(service, firstTab)
|
||||
.relatedItems
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
.map {
|
||||
StreamItem(
|
||||
url = it.url,
|
||||
title = it.name ?: "(no title)",
|
||||
uploader = it.uploaderName ?: info.name ?: "",
|
||||
uploaderUrl = it.uploaderUrl ?: channelUrl,
|
||||
thumbnail = it.thumbnails?.firstOrNull()?.url,
|
||||
durationSeconds = it.duration,
|
||||
viewCount = it.viewCount,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
_ui.value = ChannelUiState(
|
||||
loading = false,
|
||||
name = info.name ?: "",
|
||||
subscriberCount = info.subscriberCount,
|
||||
banner = info.banners?.firstOrNull()?.url,
|
||||
avatar = info.avatars?.firstOrNull()?.url,
|
||||
videos = videos,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
_ui.value = ChannelUiState(
|
||||
loading = false,
|
||||
error = t.message ?: t.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package com.sulkta.straw.feature.detail
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -44,6 +45,7 @@ fun VideoDetailScreen(
|
|||
streamUrl: String,
|
||||
initialTitle: String,
|
||||
onPlay: () -> Unit,
|
||||
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||
vm: VideoDetailViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.ui.collectAsStateWithLifecycle()
|
||||
|
|
@ -83,10 +85,14 @@ fun VideoDetailScreen(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uploaderClickable = d.uploaderUrl != null
|
||||
Text(
|
||||
text = d.uploader,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (uploaderClickable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = if (uploaderClickable) Modifier.clickable {
|
||||
onOpenChannel(d.uploaderUrl!!, d.uploader)
|
||||
} else Modifier,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ data class VideoDetail(
|
|||
val id: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
val uploaderUrl: String?,
|
||||
val viewCount: Long,
|
||||
val description: String,
|
||||
val thumbnail: String?,
|
||||
|
|
@ -83,6 +84,7 @@ class VideoDetailViewModel : ViewModel() {
|
|||
id = videoId,
|
||||
title = title,
|
||||
uploader = uploader,
|
||||
uploaderUrl = info.uploaderUrl,
|
||||
viewCount = info.viewCount,
|
||||
description = info.description?.content ?: "",
|
||||
thumbnail = thumb,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ data class StreamItem(
|
|||
val url: String,
|
||||
val title: String,
|
||||
val uploader: String,
|
||||
val uploaderUrl: String?,
|
||||
val thumbnail: String?,
|
||||
val durationSeconds: Long,
|
||||
val viewCount: Long,
|
||||
|
|
@ -72,6 +73,7 @@ class SearchViewModel : ViewModel() {
|
|||
url = it.url,
|
||||
title = it.name ?: "(no title)",
|
||||
uploader = it.uploaderName ?: "",
|
||||
uploaderUrl = it.uploaderUrl,
|
||||
thumbnail = it.thumbnails?.firstOrNull()?.url,
|
||||
durationSeconds = it.duration,
|
||||
viewCount = it.viewCount,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue