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:
Kayos 2026-05-23 19:58:37 -07:00
parent 6f5e1ed199
commit 06e6ec64e3
7 changed files with 275 additions and 1 deletions

View file

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

View file

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

View file

@ -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"
}

View file

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

View file

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

View file

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

View file

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