From 544035b30c47c311edbbf548e0022eaa6dc4c61b Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:34:02 -0700 Subject: [PATCH] =?UTF-8?q?vc=3D32:=20subs=20feed=20=E2=80=94=20dates,=20w?= =?UTF-8?q?atched=20filter,=20infinite=20scroll,=20avatar=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/search.rs | 11 ++ .../main/kotlin/com/sulkta/straw/StrawHome.kt | 138 +++++++++++++++++- .../sulkta/straw/data/SubscriptionsStore.kt | 13 ++ .../straw/feature/channel/ChannelViewModel.kt | 2 + .../feature/detail/VideoDetailViewModel.kt | 4 + .../feature/feed/SubscriptionFeedViewModel.kt | 93 +++++++++--- .../straw/feature/search/SearchViewModel.kt | 5 + 8 files changed, 244 insertions(+), 26 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index ce37f0b7c..e6a729b1a 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 = 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" diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index b4f96395e..c7f573459 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -15,11 +15,16 @@ pub struct SearchItem { pub title: String, pub uploader: String, pub uploader_url: Option, + pub uploader_avatar: Option, pub thumbnail: Option, /// 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, } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 85c4b9a6e..dd23aeae4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index b90d9b406..303ac4678 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -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 diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 90dd8ea05..fe3a38adc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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( 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 476615c25..43eaceebb 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 @@ -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()) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index f69be037f..b6fc0c49b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -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 { 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) +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index e48aecd83..f44ce5f0f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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)