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:
parent
5d9cf3e370
commit
c515fabf71
5 changed files with 160 additions and 45 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue