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