From 06e6ec64e358977aae4a04ac584d3de9a4c0cf4a Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 19:58:37 -0700 Subject: [PATCH] =?UTF-8?q?Straw=20phase=20J:=20tappable=20uploader=20?= =?UTF-8?q?=E2=86=92=20channel=20browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/main/kotlin/com/sulkta/straw/Nav.kt | 1 + .../kotlin/com/sulkta/straw/StrawActivity.kt | 11 ++ .../straw/feature/channel/ChannelScreen.kt | 169 ++++++++++++++++++ .../straw/feature/channel/ChannelViewModel.kt | 83 +++++++++ .../straw/feature/detail/VideoDetailScreen.kt | 8 +- .../feature/detail/VideoDetailViewModel.kt | 2 + .../straw/feature/search/SearchViewModel.kt | 2 + 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index 0afd64042..02396749c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 3d1fc394a..6327701bc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt new file mode 100644 index 000000000..97b0660f3 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -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" +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt new file mode 100644 index 000000000..1b8c84fbb --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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 = emptyList(), + val error: String? = null, +) + +class ChannelViewModel : ViewModel() { + private val _ui = MutableStateFlow(ChannelUiState()) + val ui: StateFlow = _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 = if (firstTab != null) { + withContext(Dispatchers.IO) { + runCatching { + ChannelTabInfo.getInfo(service, firstTab) + .relatedItems + .filterIsInstance() + .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, + ) + } + } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 6bbf3520a..5346c6005 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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)) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index f3a205f4f..fd753da3e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index cb0c5fa0a..2cabb80eb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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,