vc=32: subs feed — dates, watched filter, infinite scroll, avatar fallback

Subscription feed is now actually a feed instead of a teaser.

Rust (strawcore wrapper)
  Added upload_date_relative and uploader_avatar to SearchItem so
  Kotlin can see both. strawcore-core already extracts upload_date
  relative ("2 days ago") on every StreamInfoItem and uploader_avatars
  on most — we were just throwing them away in from_core. Fixed.

StreamItem
  uploadDateRelative + uploaderAvatar fields added. Every construction
  site (search/channel/detail/feed) plumbs them through.

SubscriptionFeedViewModel
  Per-channel cap 5 → 30. With 30 subs that's up to 900 items in
  memory; ConcurrentHashMap entries are small enough.
  Sort by parsed relative recency (RECENCY_RE on the "N <unit> ago"
  string, signed seconds-ago, tied items break by viewCount).
  Opportunistic avatar backfill: every successful channelInfo fetch
  updates the stored ChannelRef.avatar via Subscriptions.updateAvatar
  when strawcore returns a non-null avatar — fixes the "I just subbed
  to a channel and the chip has no icon" case where the channel header
  parser missed the avatar at subscribe time but the feed-fetch
  layout returns one.

SubsPane (StrawHome)
  Hide-watched FilterChip (session-sticky). Cross-references
  History.watches by 11-char YT video ID; filters out anything you've
  already watched. "All caught up — nothing unwatched" empty state.
  Infinite scroll: PAGE_SIZE = 20. derivedStateOf-gated snapshotFlow
  watches the LazyListState's lastVisibleItem index; when within 5
  items of the bottom, bumps visibleCount by 20. "Loading more..."
  spinner at the bottom while there's more to show.
  Visible-count resets to PAGE_SIZE when the underlying list shrinks
  (refresh dropped items, filter just engaged).
  FeedRow now shows: uploader · views · "3 days ago".

SubChip
  Lettered fallback when ch.avatar is null. PrimaryContainer-tinted
  circle with the first letter — no more broken-image placeholder
  while the feed-fetch backfills the real avatar.

SubscriptionsStore
  updateAvatar(url, avatar) for the backfill path. Atomic via
  updateAndGet, persists to SharedPreferences.
This commit is contained in:
Kayos 2026-05-25 12:34:02 -07:00
parent 9aafc003cb
commit 544035b30c
8 changed files with 244 additions and 26 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 = 31
const val STRAW_VERSION_NAME = "0.1.0-AQ"
const val STRAW_VERSION_CODE = 32
const val STRAW_VERSION_NAME = "0.1.0-AR"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -15,11 +15,16 @@ pub struct SearchItem {
pub title: String,
pub uploader: String,
pub uploader_url: Option<String>,
pub uploader_avatar: Option<String>,
pub thumbnail: Option<String>,
/// Duration in seconds. 0 = live/unknown.
pub duration_seconds: i64,
/// Reported view count. 0 = unknown.
pub view_count: i64,
/// Relative upload date as YT renders it ("2 days ago", "3 weeks
/// ago"). Empty if not extracted. Strawcore-core already populates
/// this on StreamInfoItem; we just pass it through.
pub upload_date_relative: String,
}
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
@ -32,11 +37,16 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
.thumbnails
.last()
.map(|i| i.url().to_string());
let uploader_avatar = item
.uploader_avatars
.last()
.map(|i| i.url().to_string());
SearchItem {
url: item.url,
title: item.name,
uploader: item.uploader_name,
uploader_url,
uploader_avatar,
thumbnail,
duration_seconds: item.duration_seconds,
view_count: if item.view_count < 0 {
@ -44,6 +54,7 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
} else {
item.view_count
},
upload_date_relative: item.upload_date_relative,
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -37,6 +38,8 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -54,11 +57,14 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -262,8 +268,35 @@ private fun SubsPane(
) {
val subs by Subscriptions.get().subs.collectAsState()
val feed by feedVm.ui.collectAsState()
val watches by History.get().watches.collectAsState()
LaunchedEffect(subs) { feedVm.refreshIfStale() }
// Filter + pagination state. hideWatched is sticky for the session
// (no SharedPreferences yet — easy to add if Cobb wants persistence).
// visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time
// the scroll passes ~5 items from the bottom of what's currently
// visible.
var hideWatched by remember { mutableStateOf(false) }
var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
// O(1) lookup for the watched-filter; rebuild only when watches
// change. Just the video IDs because URLs vary by tracking params.
val watchedIds = remember(watches) { watches.map { it.videoId }.toSet() }
val filteredItems = remember(feed.items, hideWatched, watchedIds) {
if (!hideWatched) feed.items
else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
}
// Reset pagination when the underlying list changes so the user
// doesn't end up looking at "no more items" after a refresh.
LaunchedEffect(filteredItems) {
if (visibleCount > filteredItems.size.coerceAtLeast(PAGE_SIZE)) {
visibleCount = PAGE_SIZE
}
}
val displayed = filteredItems.take(visibleCount)
val hasMore = filteredItems.size > visibleCount
Column {
if (subs.isEmpty()) {
Text(
@ -287,6 +320,13 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
FilterChip(
selected = hideWatched,
onClick = { hideWatched = !hideWatched },
label = { Text("Hide watched") },
colors = FilterChipDefaults.filterChipColors(),
)
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
@ -329,18 +369,76 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.error,
)
}
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
Text(
"All caught up — nothing unwatched.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
LazyColumn {
items(feed.items) { item ->
val listState = rememberLazyListState()
// Bump visibleCount when the user scrolls within 5 items
// of the current bottom. snapshotFlow + derivedStateOf
// keeps this off the per-frame recompose path.
val nearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= info.totalItemsCount - 5
}
}
LaunchedEffect(displayed.size, hasMore) {
snapshotFlow { nearBottom }.collect { atEnd ->
if (atEnd && hasMore) {
visibleCount = (visibleCount + PAGE_SIZE)
.coerceAtMost(filteredItems.size)
}
}
}
LazyColumn(state = listState) {
items(displayed) { item ->
FeedRow(item) { onOpenVideo(item.url, item.title) }
HorizontalDivider()
}
if (hasMore) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
"Loading more...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
private const val PAGE_SIZE = 20
/**
* Extract the YouTube video ID from a watch URL so we can cross-check
* against History.watches (which stores videoId, not full URL). Handles
* the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
* Returns empty string when nothing matches callers compare against
* watchedIds, so an empty string just won't filter anything out.
*/
private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?&#].*)?$")
private fun extractVideoId(url: String): String =
VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
@Composable
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
Row(
@ -374,6 +472,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
append(" · ")
append(formatViews(item.viewCount))
}
if (item.uploadDateRelative.isNotBlank()) {
append(" · ")
append(item.uploadDateRelative)
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -437,11 +539,33 @@ private fun SubChip(
.clickable { onOpenChannel(ch.url, ch.name) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = ch.avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
if (ch.avatar.isNullOrBlank()) {
// Lettered fallback — strawcore can return a null avatar
// when the channel header layout doesn't include one (more
// common on smaller channels). Feed-fetch backfills this
// asynchronously via Subscriptions.updateAvatar, but until
// it arrives we still want SOMETHING visible.
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = ch.name.firstOrNull()?.uppercase().orEmpty(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
)
}
} else {
AsyncImage(
model = ch.avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = ch.name,

View file

@ -51,6 +51,19 @@ class SubscriptionsStore(context: Context) {
persist(next)
}
/**
* Update the cached avatar for an already-subscribed channel. Used
* by the subs feed fetch when it pulls a fresh ChannelInfo and the
* stored ChannelRef has a null avatar (channel header parser missed
* it at subscribe time). No-op for non-subscribed URLs.
*/
fun updateAvatar(channelUrl: String, avatar: String) {
val next = _subs.updateAndGet { cur ->
cur.map { if (it.url == channelUrl) it.copy(avatar = avatar) else it }
}
persist(next)
}
fun clear() {
// Same atomic-update path as toggle — protects against a concurrent
// toggle racing the clear and persisting [new-item] after the

View file

@ -42,9 +42,11 @@ class ChannelViewModel : ViewModel() {
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl,
uploaderAvatar = v.uploaderAvatar ?: ch.avatar,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
_ui.value = ChannelUiState(

View file

@ -129,9 +129,11 @@ class VideoDetailViewModel : ViewModel() {
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
uploaderAvatar = r.uploaderAvatar,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
@ -151,9 +153,11 @@ class VideoDetailViewModel : ViewModel() {
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader },
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
uploaderAvatar = v.uploaderAvatar ?: ch.avatar,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
}.getOrDefault(emptyList())

View file

@ -3,18 +3,15 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Aggregate latest videos across all subscribed channels into a single
* feed. Fans out per-channel channelInfo() fetches in parallel, caches
* each channel's videos independently, merges by view-count desc, caps
* at 200 items.
* feed. Per-channel fan-out with independent TTL caches. Bigger per
* channel limit so the feed actually feels "show me everything new",
* sorted by parsed relative upload date so the merged list reads
* newest-first across channels.
*
* Each per-channel cache entry has its own TTL so adding one new
* subscription doesn't invalidate the other 49 only the new one
* actually goes to the network on the next refresh.
*
* Concurrency hardening: cancel any in-flight refresh when a new one
* starts, cap parallelism with a Semaphore so 100+ subs don't slam YT,
* time-bound each per-channel fetch so one hung channel can't stall the
* whole batch.
* Also opportunistically refreshes a channel's avatar in
* SubscriptionsStore strawcore can occasionally return null on first
* subscribe (the channel header layout varies); a subsequent feed fetch
* will fill it in automatically.
*/
package com.sulkta.straw.feature.feed
@ -63,6 +60,13 @@ class SubscriptionFeedViewModel : ViewModel() {
/** Cap parallel network fetches even with 100+ subs. */
private val parallelism = 8
/**
* Videos pulled per channel. Bumped from 5 30 so "show me
* everything new from my subs" actually has body to it; cheap to
* keep in memory at this size (30 subs * 30 videos = 900 max).
*/
private val perChannelMax = 30
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null
@ -116,19 +120,30 @@ class SubscriptionFeedViewModel : ViewModel() {
}
private suspend fun fetchChannelInto(ch: ChannelRef) {
val perChannelMax = 5
val fetched = withTimeoutOrNull(perChannelTimeoutMs) {
val outcome = withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = uniffi.strawcore.channelInfo(ch.url)
// Opportunistic avatar refresh: if our stored ChannelRef
// didn't capture an avatar at subscribe-time (channel
// header parser missed it, or user subscribed before the
// page loaded), backfill from the channel info now.
val freshAvatar = info.avatar
if (!freshAvatar.isNullOrBlank() && freshAvatar != ch.avatar) {
runCatching {
Subscriptions.get().updateAvatar(ch.url, freshAvatar)
}
}
info.videos.take(perChannelMax).map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
uploaderAvatar = v.uploaderAvatar ?: freshAvatar ?: ch.avatar,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
uploadDateRelative = v.uploadDateRelative,
)
}
}.onFailure {
@ -141,8 +156,8 @@ class SubscriptionFeedViewModel : ViewModel() {
// Only update the cache on a successful fetch. A timeout/error
// leaves any prior cache entry intact, so a glitchy channel
// doesn't blank your feed for that channel.
if (fetched.isNotEmpty()) {
channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), fetched)
if (outcome.isNotEmpty()) {
channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), outcome)
}
}
@ -152,7 +167,51 @@ class SubscriptionFeedViewModel : ViewModel() {
// fall out of the feed immediately.
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
.sortedByDescending { it.viewCount }
.take(200)
// Newest-first across channels. Falls back to viewCount when
// we couldn't parse the relative date (older items + live
// streams come back without one).
.sortedWith(
compareByDescending<StreamItem> { it.recencyScore() }
.thenByDescending { it.viewCount },
)
// Generous cap. Anything past this is almost certainly noise
// for a feed view; pagination in the UI further slices this.
.take(500)
}
}
/**
* Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style
* strings into approximate seconds-ago. Higher = more recent (so default
* sort is descending). Returns Long.MIN_VALUE when we can't parse those
* sink to the bottom of the feed.
*
* Strawcore-core (and YT before it) emits these in English-only locale
* for the InnerTube web client; if we ever localize the extractor this
* regex needs to grow.
*/
private val RECENCY_RE = Regex(
"""(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago""",
RegexOption.IGNORE_CASE,
)
private fun StreamItem.recencyScore(): Long {
val s = uploadDateRelative
if (s.isBlank()) return Long.MIN_VALUE
val m = RECENCY_RE.find(s) ?: return Long.MIN_VALUE
val n = m.groupValues[1].toLongOrNull() ?: return Long.MIN_VALUE
val unitSecs: Long = when (m.groupValues[2].lowercase()) {
"second" -> 1
"minute" -> 60
"hour" -> 3600
"day" -> 86_400
"week" -> 604_800
"month" -> 2_592_000 // approx 30 days
"year" -> 31_536_000
else -> return Long.MIN_VALUE
}
// Sign flip: smaller "seconds ago" → larger score (more recent).
// Cap at a sane horizon so a "1 second ago" doesn't overwhelm the
// viewCount tiebreaker on items that are functionally tied.
return -(n * unitSecs)
}

View file

@ -25,9 +25,12 @@ data class StreamItem(
val title: String,
val uploader: String,
val uploaderUrl: String?,
val uploaderAvatar: String? = null,
val thumbnail: String?,
val durationSeconds: Long,
val viewCount: Long,
/** "2 days ago" / "3 weeks ago" / empty if not extracted. */
val uploadDateRelative: String = "",
)
class SearchViewModel : ViewModel() {
@ -53,9 +56,11 @@ class SearchViewModel : ViewModel() {
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
uploaderAvatar = r.uploaderAvatar,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
uploadDateRelative = r.uploadDateRelative,
)
}
_ui.value = _ui.value.copy(loading = false, results = items)