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:
Sulkta 2026-05-25 12:34:02 -07:00
parent 5573344fb9
commit c80be35d7b
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 // 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"

View file

@ -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,
} }
} }

View file

@ -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,

View file

@ -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

View file

@ -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(

View file

@ -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())

View file

@ -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)
}

View file

@ -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)