From c515fabf71473c24f1e5c0444c15328dc9a2c0bd Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 22:11:12 -0700 Subject: [PATCH] vc=45: tappable channel name in search + channel row on VideoDetail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../kotlin/com/sulkta/straw/StrawActivity.kt | 1 + .../straw/feature/detail/VideoDetailScreen.kt | 74 ++++++++++++++++--- .../feature/detail/VideoDetailViewModel.kt | 69 ++++++++++++----- .../straw/feature/search/SearchScreen.kt | 57 ++++++++++---- 5 files changed, 160 insertions(+), 45 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 839b5c708..07476195c 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 9bcb0664e..7d5f00e18 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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, 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 ea3800279..b2209bcc0 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 @@ -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( 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 33b81c305..7c11976f9 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 @@ -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 = - if (uploaderUrl.isNullOrBlank() || !isAllowedYtUrl(uploaderUrl)) emptyList() - else runCatchingCancellable { + data class ChannelExtras( + val avatar: String?, + val subscriberCount: Long, + val videos: List, + ) + 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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index c0c7a3434..3a8b94ea2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -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, + ) + } } } }