vc=45: tappable channel name in search + channel row on VideoDetail

Search results:
  - Uploader name split onto its own line at bodyMedium (was bodySmall).
  - Clickable when uploaderUrl present — taps land on the channel page.
  - Tinted primary when clickable, neutral when not.
  - Views/duration moved to a separate line so they don't fight the
    larger uploader tap target.

VideoDetail:
  - New channel row below the title: avatar (40dp circle, clickable),
    name (titleSmall semibold, clickable), subscriber count,
    Subscribe/Subscribed button on the right.
  - Avatar + subscriber count pulled from the same strawcore.channelInfo
    call that already runs for moreFromChannel — no extra round-trip.
  - Opportunistically pushes a fresh avatar back to SubscriptionsStore
    on resolution so the subs feed picks it up too (mirrors the existing
    backfill in SubscriptionFeedViewModel.fetchChannelInto).
This commit is contained in:
Kayos 2026-05-25 22:11:12 -07:00
parent 5d9cf3e370
commit c515fabf71
5 changed files with 160 additions and 45 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 44 const val STRAW_VERSION_CODE = 45
const val STRAW_VERSION_NAME = "0.1.0-BD" const val STRAW_VERSION_NAME = "0.1.0-BE"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -166,6 +166,7 @@ class StrawActivity : ComponentActivity() {
is Screen.Settings -> SettingsScreen() is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen( is Screen.Search -> SearchScreen(
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
) )
is Screen.VideoDetail -> VideoDetailScreen( is Screen.VideoDetail -> VideoDetailScreen(
streamUrl = s.streamUrl, streamUrl = s.streamUrl,

View file

@ -100,6 +100,8 @@ import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.player.setPlayingFrom
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.LogDump
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
@ -296,17 +298,69 @@ fun VideoDetailScreen(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
val uploaderUrl = d.uploaderUrl val uploaderUrl = d.uploaderUrl
Text( // Channel row: avatar + name (larger, clickable when we
text = d.uploader, // have a uploaderUrl) + Subscribe / Subscribed toggle.
style = MaterialTheme.typography.bodyMedium, // Matches the YouTube/NewPipe layout below the title.
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
else MaterialTheme.colorScheme.onSurfaceVariant, val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
modifier = if (uploaderUrl != null) Modifier.clickable { Row(
onOpenChannel(uploaderUrl, d.uploader) verticalAlignment = Alignment.CenterVertically,
} else Modifier, modifier = Modifier.fillMaxWidth(),
) ) {
if (!d.uploaderAvatar.isNullOrBlank()) {
AsyncImage(
model = d.uploaderAvatar,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.then(
if (uploaderUrl != null)
Modifier.clickable { onOpenChannel(uploaderUrl, d.uploader) }
else Modifier
),
)
Spacer(modifier = Modifier.width(10.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = d.uploader,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = if (uploaderUrl != null) Modifier
.clickable { onOpenChannel(uploaderUrl, d.uploader) }
.padding(vertical = 4.dp)
else Modifier.padding(vertical = 4.dp),
)
if (d.uploaderSubscriberCount > 0) {
Text(
text = "${formatCount(d.uploaderSubscriberCount)} subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (uploaderUrl != null) {
val onSubClick = {
Subscriptions.get().toggle(
ChannelRef(
url = uploaderUrl,
name = d.uploader,
avatar = d.uploaderAvatar,
),
)
}
if (isSubscribed) {
OutlinedButton(onClick = onSubClick) { Text("Subscribed") }
} else {
Button(onClick = onSubClick) { Text("Subscribe") }
}
}
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( Row(

View file

@ -41,6 +41,15 @@ data class VideoDetail(
val title: String, val title: String,
val uploader: String, val uploader: String,
val uploaderUrl: String?, val uploaderUrl: String?,
/**
* Uploader's channel avatar (square-ish thumbnail). Populated
* from the same strawcore.channelInfo call that fills
* `moreFromChannel`; null until that call resolves, or when the
* uploaderUrl is missing / fails the allowlist. Renders as a
* small circle next to the channel name on VideoDetail.
*/
val uploaderAvatar: String? = null,
val uploaderSubscriberCount: Long = -1,
val viewCount: Long, val viewCount: Long,
val description: String, val description: String,
val thumbnail: String?, val thumbnail: String?,
@ -180,26 +189,48 @@ class VideoDetailViewModel : ViewModel() {
// extractor would otherwise trigger an arbitrary-host // extractor would otherwise trigger an arbitrary-host
// network call. Round-4 audit HIGH-4. // network call. Round-4 audit HIGH-4.
val uploaderUrl = info.uploaderUrl val uploaderUrl = info.uploaderUrl
val moreFromChannel: List<StreamItem> = data class ChannelExtras(
if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) emptyList() val avatar: String?,
else runCatchingCancellable { val subscriberCount: Long,
val videos: List<StreamItem>,
)
val channelExtras: ChannelExtras =
if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) {
ChannelExtras(null, -1, emptyList())
} else runCatchingCancellable {
val ch = uniffi.strawcore.channelInfo(uploaderUrl) val ch = uniffi.strawcore.channelInfo(uploaderUrl)
ch.videos // Opportunistic avatar refresh: if the user is
.filter { it.url != streamUrl } // subscribed and our stored avatar is stale or
.take(20) // missing, push the fresh one back to the store
.map { v -> // so the subs feed picks it up too.
StreamItem( val fresh = ch.avatar
url = v.url, if (!fresh.isNullOrBlank()) {
title = v.title.ifBlank { "(no title)" }, runCatchingCancellable {
uploader = v.uploader.ifBlank { uploader }, com.sulkta.straw.data.Subscriptions
uploaderUrl = v.uploaderUrl ?: uploaderUrl, .get().updateAvatar(uploaderUrl, fresh)
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
} }
}.getOrDefault(emptyList()) }
ChannelExtras(
avatar = fresh,
subscriberCount = ch.subscriberCount,
videos = ch.videos
.filter { it.url != streamUrl }
.take(20)
.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader },
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
},
)
}.getOrDefault(ChannelExtras(null, -1, emptyList()))
val moreFromChannel = channelExtras.videos
val resolved = resolvePlayback(info, segments) val resolved = resolvePlayback(info, segments)
@ -217,6 +248,8 @@ class VideoDetailViewModel : ViewModel() {
title = title, title = title,
uploader = uploader, uploader = uploader,
uploaderUrl = info.uploaderUrl, uploaderUrl = info.uploaderUrl,
uploaderAvatar = channelExtras.avatar,
uploaderSubscriberCount = channelExtras.subscriberCount,
viewCount = info.viewCount, viewCount = info.viewCount,
description = info.description, description = info.description,
thumbnail = thumb, thumbnail = thumb,

View file

@ -50,6 +50,7 @@ import com.sulkta.straw.util.formatViews
@Composable @Composable
fun SearchScreen( fun SearchScreen(
onOpenVideo: (url: String, title: String) -> Unit, onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (url: String, name: String) -> Unit,
vm: SearchViewModel = viewModel(), vm: SearchViewModel = viewModel(),
) { ) {
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
@ -149,7 +150,11 @@ fun SearchScreen(
} }
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.results) { item -> items(state.results) { item ->
ResultRow(item = item) { onOpenVideo(item.url, item.title) } ResultRow(
item = item,
onClick = { onOpenVideo(item.url, item.title) },
onChannelClick = { url -> onOpenChannel(url, item.uploader) },
)
HorizontalDivider() HorizontalDivider()
} }
} }
@ -159,7 +164,11 @@ fun SearchScreen(
} }
@Composable @Composable
private fun ResultRow(item: StreamItem, onClick: () -> Unit) { private fun ResultRow(
item: StreamItem,
onClick: () -> Unit,
onChannelClick: (url: String) -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -185,23 +194,41 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Uploader on its own line — larger + tinted + clickable
// when we have a uploaderUrl to route to. Tapping the
// name jumps to the Channel screen; tapping anywhere else
// on the row still opens the video. Child clickable
// consumes the press before the row's clickable hears it.
val uploaderUrl = item.uploaderUrl
Text( Text(
text = buildString { text = item.uploader,
append(item.uploader) style = MaterialTheme.typography.bodyMedium,
if (item.viewCount > 0) { color = if (!uploaderUrl.isNullOrBlank())
append(" · ") MaterialTheme.colorScheme.primary
append(formatViews(item.viewCount)) else
} MaterialTheme.colorScheme.onSurfaceVariant,
if (item.durationSeconds > 0) {
append(" · ")
append(formatDuration(item.durationSeconds))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = if (!uploaderUrl.isNullOrBlank())
Modifier
.clickable { onChannelClick(uploaderUrl) }
.padding(vertical = 4.dp)
else
Modifier.padding(vertical = 4.dp),
) )
if (item.viewCount > 0 || item.durationSeconds > 0) {
Text(
text = buildString {
if (item.viewCount > 0) append(formatViews(item.viewCount))
if (item.viewCount > 0 && item.durationSeconds > 0) append(" · ")
if (item.durationSeconds > 0) append(formatDuration(item.durationSeconds))
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
} }