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
b2d63bb353
commit
db941169e7
7 changed files with 275 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ sealed interface Screen {
|
||||||
data object Settings : Screen
|
data object Settings : Screen
|
||||||
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
data class VideoDetail(val streamUrl: String, val title: String) : Screen
|
||||||
data class Player(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) {
|
class Navigator(initial: Screen) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.sulkta.straw.feature.channel.ChannelScreen
|
||||||
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
import com.sulkta.straw.feature.detail.VideoDetailScreen
|
||||||
import com.sulkta.straw.feature.player.PlayerScreen
|
import com.sulkta.straw.feature.player.PlayerScreen
|
||||||
import com.sulkta.straw.feature.search.SearchScreen
|
import com.sulkta.straw.feature.search.SearchScreen
|
||||||
|
|
@ -75,6 +76,16 @@ class StrawActivity : ComponentActivity() {
|
||||||
onPlay = {
|
onPlay = {
|
||||||
nav.push(Screen.Player(s.streamUrl, s.title))
|
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(
|
is Screen.Player -> PlayerScreen(
|
||||||
streamUrl = s.streamUrl,
|
streamUrl = s.streamUrl,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* 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
|
||||||
|
* 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
|
package com.sulkta.straw.feature.detail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
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.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -44,6 +45,7 @@ fun VideoDetailScreen(
|
||||||
streamUrl: String,
|
streamUrl: String,
|
||||||
initialTitle: String,
|
initialTitle: String,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
|
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||||
vm: VideoDetailViewModel = viewModel(),
|
vm: VideoDetailViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
|
|
@ -83,10 +85,14 @@ fun VideoDetailScreen(
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
val uploaderClickable = d.uploaderUrl != null
|
||||||
Text(
|
Text(
|
||||||
text = d.uploader,
|
text = d.uploader,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ data class VideoDetail(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val uploader: String,
|
val uploader: String,
|
||||||
|
val uploaderUrl: String?,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
val description: String,
|
val description: String,
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
|
|
@ -83,6 +84,7 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
id = videoId,
|
id = videoId,
|
||||||
title = title,
|
title = title,
|
||||||
uploader = uploader,
|
uploader = uploader,
|
||||||
|
uploaderUrl = info.uploaderUrl,
|
||||||
viewCount = info.viewCount,
|
viewCount = info.viewCount,
|
||||||
description = info.description?.content ?: "",
|
description = info.description?.content ?: "",
|
||||||
thumbnail = thumb,
|
thumbnail = thumb,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ data class StreamItem(
|
||||||
val url: String,
|
val url: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val uploader: String,
|
val uploader: String,
|
||||||
|
val uploaderUrl: String?,
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
val durationSeconds: Long,
|
val durationSeconds: Long,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
|
|
@ -72,6 +73,7 @@ class SearchViewModel : ViewModel() {
|
||||||
url = it.url,
|
url = it.url,
|
||||||
title = it.name ?: "(no title)",
|
title = it.name ?: "(no title)",
|
||||||
uploader = it.uploaderName ?: "",
|
uploader = it.uploaderName ?: "",
|
||||||
|
uploaderUrl = it.uploaderUrl,
|
||||||
thumbnail = it.thumbnails?.firstOrNull()?.url,
|
thumbnail = it.thumbnails?.firstOrNull()?.url,
|
||||||
durationSeconds = it.duration,
|
durationSeconds = it.duration,
|
||||||
viewCount = it.viewCount,
|
viewCount = it.viewCount,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue