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
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 44
const val STRAW_VERSION_NAME = "0.1.0-BD"
const val STRAW_VERSION_CODE = 45
const val STRAW_VERSION_NAME = "0.1.0-BE"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -166,6 +166,7 @@ class StrawActivity : ComponentActivity() {
is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
)
is Screen.VideoDetail -> VideoDetailScreen(
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.search.StreamItem
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.formatViews
import com.sulkta.straw.util.stripHtml
@ -296,17 +298,69 @@ fun VideoDetailScreen(
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(8.dp))
val uploaderUrl = d.uploaderUrl
Text(
text = d.uploader,
style = MaterialTheme.typography.bodyMedium,
color = if (uploaderUrl != null) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = if (uploaderUrl != null) Modifier.clickable {
onOpenChannel(uploaderUrl, d.uploader)
} else Modifier,
)
// Channel row: avatar + name (larger, clickable when we
// have a uploaderUrl) + Subscribe / Subscribed toggle.
// Matches the YouTube/NewPipe layout below the title.
val subs by Subscriptions.get().subs.collectAsStateWithLifecycle()
val isSubscribed = uploaderUrl != null && subs.any { it.url == uploaderUrl }
Row(
verticalAlignment = Alignment.CenterVertically,
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))
Row(

View file

@ -41,6 +41,15 @@ data class VideoDetail(
val title: String,
val uploader: 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 description: String,
val thumbnail: String?,
@ -180,26 +189,48 @@ class VideoDetailViewModel : ViewModel() {
// extractor would otherwise trigger an arbitrary-host
// network call. Round-4 audit HIGH-4.
val uploaderUrl = info.uploaderUrl
val moreFromChannel: List<StreamItem> =
if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) emptyList()
else runCatchingCancellable {
data class ChannelExtras(
val avatar: String?,
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)
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,
)
// Opportunistic avatar refresh: if the user is
// subscribed and our stored avatar is stale or
// missing, push the fresh one back to the store
// so the subs feed picks it up too.
val fresh = ch.avatar
if (!fresh.isNullOrBlank()) {
runCatchingCancellable {
com.sulkta.straw.data.Subscriptions
.get().updateAvatar(uploaderUrl, fresh)
}
}.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)
@ -217,6 +248,8 @@ class VideoDetailViewModel : ViewModel() {
title = title,
uploader = uploader,
uploaderUrl = info.uploaderUrl,
uploaderAvatar = channelExtras.avatar,
uploaderSubscriberCount = channelExtras.subscriberCount,
viewCount = info.viewCount,
description = info.description,
thumbnail = thumb,

View file

@ -50,6 +50,7 @@ import com.sulkta.straw.util.formatViews
@Composable
fun SearchScreen(
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (url: String, name: String) -> Unit,
vm: SearchViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
@ -149,7 +150,11 @@ fun SearchScreen(
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
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()
}
}
@ -159,7 +164,11 @@ fun SearchScreen(
}
@Composable
private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
private fun ResultRow(
item: StreamItem,
onClick: () -> Unit,
onChannelClick: (url: String) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -185,23 +194,41 @@ private fun ResultRow(item: StreamItem, onClick: () -> Unit) {
overflow = TextOverflow.Ellipsis,
)
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 = buildString {
append(item.uploader)
if (item.viewCount > 0) {
append(" · ")
append(formatViews(item.viewCount))
}
if (item.durationSeconds > 0) {
append(" · ")
append(formatDuration(item.durationSeconds))
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = item.uploader,
style = MaterialTheme.typography.bodyMedium,
color = if (!uploaderUrl.isNullOrBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
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,
)
}
}
}
}