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
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue