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:
parent
5573344fb9
commit
c80be35d7b
8 changed files with 244 additions and 26 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-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 31
|
const val STRAW_VERSION_CODE = 32
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AQ"
|
const val STRAW_VERSION_NAME = "0.1.0-AR"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,16 @@ pub struct SearchItem {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub uploader: String,
|
pub uploader: String,
|
||||||
pub uploader_url: Option<String>,
|
pub uploader_url: Option<String>,
|
||||||
|
pub uploader_avatar: Option<String>,
|
||||||
pub thumbnail: Option<String>,
|
pub thumbnail: Option<String>,
|
||||||
/// Duration in seconds. 0 = live/unknown.
|
/// Duration in seconds. 0 = live/unknown.
|
||||||
pub duration_seconds: i64,
|
pub duration_seconds: i64,
|
||||||
/// Reported view count. 0 = unknown.
|
/// Reported view count. 0 = unknown.
|
||||||
pub view_count: i64,
|
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 {
|
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
|
|
@ -32,11 +37,16 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
.thumbnails
|
.thumbnails
|
||||||
.last()
|
.last()
|
||||||
.map(|i| i.url().to_string());
|
.map(|i| i.url().to_string());
|
||||||
|
let uploader_avatar = item
|
||||||
|
.uploader_avatars
|
||||||
|
.last()
|
||||||
|
.map(|i| i.url().to_string());
|
||||||
SearchItem {
|
SearchItem {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
uploader: item.uploader_name,
|
uploader: item.uploader_name,
|
||||||
uploader_url,
|
uploader_url,
|
||||||
|
uploader_avatar,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
duration_seconds: item.duration_seconds,
|
duration_seconds: item.duration_seconds,
|
||||||
view_count: if item.view_count < 0 {
|
view_count: if item.view_count < 0 {
|
||||||
|
|
@ -44,6 +54,7 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
} else {
|
} else {
|
||||||
item.view_count
|
item.view_count
|
||||||
},
|
},
|
||||||
|
upload_date_relative: item.upload_date_relative,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -54,11 +57,14 @@ import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -262,8 +268,35 @@ private fun SubsPane(
|
||||||
) {
|
) {
|
||||||
val subs by Subscriptions.get().subs.collectAsState()
|
val subs by Subscriptions.get().subs.collectAsState()
|
||||||
val feed by feedVm.ui.collectAsState()
|
val feed by feedVm.ui.collectAsState()
|
||||||
|
val watches by History.get().watches.collectAsState()
|
||||||
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
LaunchedEffect(subs) { feedVm.refreshIfStale() }
|
||||||
|
|
||||||
|
// Filter + pagination state. hideWatched is sticky for the session
|
||||||
|
// (no SharedPreferences yet — easy to add if a tester 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 {
|
Column {
|
||||||
if (subs.isEmpty()) {
|
if (subs.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -287,6 +320,13 @@ private fun SubsPane(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
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() }) {
|
TextButton(onClick = { feedVm.refresh() }) {
|
||||||
Text(if (feed.loading) "..." else "Refresh")
|
Text(if (feed.loading) "..." else "Refresh")
|
||||||
}
|
}
|
||||||
|
|
@ -329,18 +369,76 @@ private fun SubsPane(
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
|
||||||
|
Text(
|
||||||
|
"All caught up — nothing unwatched.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn {
|
val listState = rememberLazyListState()
|
||||||
items(feed.items) { item ->
|
// 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) }
|
FeedRow(item) { onOpenVideo(item.url, item.title) }
|
||||||
HorizontalDivider()
|
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
|
@Composable
|
||||||
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -374,6 +472,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
|
||||||
append(" · ")
|
append(" · ")
|
||||||
append(formatViews(item.viewCount))
|
append(formatViews(item.viewCount))
|
||||||
}
|
}
|
||||||
|
if (item.uploadDateRelative.isNotBlank()) {
|
||||||
|
append(" · ")
|
||||||
|
append(item.uploadDateRelative)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|
@ -437,11 +539,33 @@ private fun SubChip(
|
||||||
.clickable { onOpenChannel(ch.url, ch.name) },
|
.clickable { onOpenChannel(ch.url, ch.name) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
if (ch.avatar.isNullOrBlank()) {
|
||||||
model = ch.avatar,
|
// Lettered fallback — strawcore can return a null avatar
|
||||||
contentDescription = null,
|
// when the channel header layout doesn't include one (more
|
||||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
// 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))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = ch.name,
|
text = ch.name,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,19 @@ class SubscriptionsStore(context: Context) {
|
||||||
persist(next)
|
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() {
|
fun clear() {
|
||||||
// Same atomic-update path as toggle — protects against a concurrent
|
// Same atomic-update path as toggle — protects against a concurrent
|
||||||
// toggle racing the clear and persisting [new-item] after the
|
// toggle racing the clear and persisting [new-item] after the
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,11 @@ class ChannelViewModel : ViewModel() {
|
||||||
title = v.title.ifBlank { "(no title)" },
|
title = v.title.ifBlank { "(no title)" },
|
||||||
uploader = v.uploader,
|
uploader = v.uploader,
|
||||||
uploaderUrl = v.uploaderUrl,
|
uploaderUrl = v.uploaderUrl,
|
||||||
|
uploaderAvatar = v.uploaderAvatar ?: ch.avatar,
|
||||||
thumbnail = v.thumbnail,
|
thumbnail = v.thumbnail,
|
||||||
durationSeconds = v.durationSeconds,
|
durationSeconds = v.durationSeconds,
|
||||||
viewCount = v.viewCount,
|
viewCount = v.viewCount,
|
||||||
|
uploadDateRelative = v.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ui.value = ChannelUiState(
|
_ui.value = ChannelUiState(
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,11 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
title = r.title.ifBlank { "(no title)" },
|
title = r.title.ifBlank { "(no title)" },
|
||||||
uploader = r.uploader,
|
uploader = r.uploader,
|
||||||
uploaderUrl = r.uploaderUrl,
|
uploaderUrl = r.uploaderUrl,
|
||||||
|
uploaderAvatar = r.uploaderAvatar,
|
||||||
thumbnail = r.thumbnail,
|
thumbnail = r.thumbnail,
|
||||||
durationSeconds = r.durationSeconds,
|
durationSeconds = r.durationSeconds,
|
||||||
viewCount = r.viewCount,
|
viewCount = r.viewCount,
|
||||||
|
uploadDateRelative = r.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,9 +153,11 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
title = v.title.ifBlank { "(no title)" },
|
title = v.title.ifBlank { "(no title)" },
|
||||||
uploader = v.uploader.ifBlank { uploader },
|
uploader = v.uploader.ifBlank { uploader },
|
||||||
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
|
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
|
||||||
|
uploaderAvatar = v.uploaderAvatar ?: ch.avatar,
|
||||||
thumbnail = v.thumbnail,
|
thumbnail = v.thumbnail,
|
||||||
durationSeconds = v.durationSeconds,
|
durationSeconds = v.durationSeconds,
|
||||||
viewCount = v.viewCount,
|
viewCount = v.viewCount,
|
||||||
|
uploadDateRelative = v.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,15 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* Aggregate latest videos across all subscribed channels into a single
|
* Aggregate latest videos across all subscribed channels into a single
|
||||||
* feed. Fans out per-channel channelInfo() fetches in parallel, caches
|
* feed. Per-channel fan-out with independent TTL caches. Bigger per
|
||||||
* each channel's videos independently, merges by view-count desc, caps
|
* channel limit so the feed actually feels "show me everything new",
|
||||||
* at 200 items.
|
* 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
|
* Also opportunistically refreshes a channel's avatar in
|
||||||
* subscription doesn't invalidate the other 49 — only the new one
|
* SubscriptionsStore — strawcore can occasionally return null on first
|
||||||
* actually goes to the network on the next refresh.
|
* subscribe (the channel header layout varies); a subsequent feed fetch
|
||||||
*
|
* will fill it in automatically.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.sulkta.straw.feature.feed
|
package com.sulkta.straw.feature.feed
|
||||||
|
|
@ -63,6 +60,13 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
/** Cap parallel network fetches even with 100+ subs. */
|
/** Cap parallel network fetches even with 100+ subs. */
|
||||||
private val parallelism = 8
|
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. */
|
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
|
||||||
private var inFlight: Job? = null
|
private var inFlight: Job? = null
|
||||||
|
|
||||||
|
|
@ -116,19 +120,30 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchChannelInto(ch: ChannelRef) {
|
private suspend fun fetchChannelInto(ch: ChannelRef) {
|
||||||
val perChannelMax = 5
|
val outcome = withTimeoutOrNull(perChannelTimeoutMs) {
|
||||||
val fetched = withTimeoutOrNull(perChannelTimeoutMs) {
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val info = uniffi.strawcore.channelInfo(ch.url)
|
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 ->
|
info.videos.take(perChannelMax).map { v ->
|
||||||
StreamItem(
|
StreamItem(
|
||||||
url = v.url,
|
url = v.url,
|
||||||
title = v.title.ifBlank { "(no title)" },
|
title = v.title.ifBlank { "(no title)" },
|
||||||
uploader = v.uploader.ifBlank { ch.name },
|
uploader = v.uploader.ifBlank { ch.name },
|
||||||
uploaderUrl = v.uploaderUrl ?: ch.url,
|
uploaderUrl = v.uploaderUrl ?: ch.url,
|
||||||
|
uploaderAvatar = v.uploaderAvatar ?: freshAvatar ?: ch.avatar,
|
||||||
thumbnail = v.thumbnail,
|
thumbnail = v.thumbnail,
|
||||||
durationSeconds = v.durationSeconds,
|
durationSeconds = v.durationSeconds,
|
||||||
viewCount = v.viewCount,
|
viewCount = v.viewCount,
|
||||||
|
uploadDateRelative = v.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
|
@ -141,8 +156,8 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
// Only update the cache on a successful fetch. A timeout/error
|
// Only update the cache on a successful fetch. A timeout/error
|
||||||
// leaves any prior cache entry intact, so a glitchy channel
|
// leaves any prior cache entry intact, so a glitchy channel
|
||||||
// doesn't blank your feed for that channel.
|
// doesn't blank your feed for that channel.
|
||||||
if (fetched.isNotEmpty()) {
|
if (outcome.isNotEmpty()) {
|
||||||
channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), fetched)
|
channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), outcome)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +167,51 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
// fall out of the feed immediately.
|
// fall out of the feed immediately.
|
||||||
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
|
channelCache.keys.toList().forEach { if (it !in subUrls) channelCache.remove(it) }
|
||||||
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
|
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
|
||||||
.sortedByDescending { it.viewCount }
|
// Newest-first across channels. Falls back to viewCount when
|
||||||
.take(200)
|
// 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,12 @@ data class StreamItem(
|
||||||
val title: String,
|
val title: String,
|
||||||
val uploader: String,
|
val uploader: String,
|
||||||
val uploaderUrl: String?,
|
val uploaderUrl: String?,
|
||||||
|
val uploaderAvatar: String? = null,
|
||||||
val thumbnail: String?,
|
val thumbnail: String?,
|
||||||
val durationSeconds: Long,
|
val durationSeconds: Long,
|
||||||
val viewCount: Long,
|
val viewCount: Long,
|
||||||
|
/** "2 days ago" / "3 weeks ago" / empty if not extracted. */
|
||||||
|
val uploadDateRelative: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
class SearchViewModel : ViewModel() {
|
class SearchViewModel : ViewModel() {
|
||||||
|
|
@ -53,9 +56,11 @@ class SearchViewModel : ViewModel() {
|
||||||
title = r.title.ifBlank { "(no title)" },
|
title = r.title.ifBlank { "(no title)" },
|
||||||
uploader = r.uploader,
|
uploader = r.uploader,
|
||||||
uploaderUrl = r.uploaderUrl,
|
uploaderUrl = r.uploaderUrl,
|
||||||
|
uploaderAvatar = r.uploaderAvatar,
|
||||||
thumbnail = r.thumbnail,
|
thumbnail = r.thumbnail,
|
||||||
durationSeconds = r.durationSeconds,
|
durationSeconds = r.durationSeconds,
|
||||||
viewCount = r.viewCount,
|
viewCount = r.viewCount,
|
||||||
|
uploadDateRelative = r.uploadDateRelative,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ui.value = _ui.value.copy(loading = false, results = items)
|
_ui.value = _ui.value.copy(loading = false, results = items)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue