straw: infinite-scroll pagination for channel + search
All checks were successful
gitleaks / scan (push) Successful in 41s
All checks were successful
gitleaks / scan (push) Successful in 41s
Wire the new strawcore continuation fetchers through UniFFI and add
load-more-on-scroll to the Channel and Search screens — previously both
loaded only page 1 and stopped.
FFI (rust/strawcore): search() now returns Page{items, continuation};
channelInfo carries videos_continuation; new searchContinuation() and
channelVideosContinuation() suspend funs map the core ContinuationPage.
Channel + Search ViewModels: loadMore() fetches the next page, dedups by
url, advances the token, and stops when the token runs out or a page
yields zero net-new items (guards a looping continuation). Result-set
swaps (channel switch / new submit / cache preview) cancel the in-flight
page, and a token fence inside the state update prevents a stale page
being spliced into a replaced list. Screens add a near-end LazyColumn
trigger (rememberLazyListState + derivedStateOf) and a footer spinner.
This commit is contained in:
parent
6f2ae831cc
commit
4dfb2e1450
8 changed files with 2670 additions and 9 deletions
2364
rust/Cargo.lock
generated
Normal file
2364
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,14 @@
|
|||
// Used by ChannelScreen (single-channel view) AND
|
||||
// SubscriptionFeedViewModel (which fans out across all subscriptions).
|
||||
|
||||
use strawcore_core::youtube::channel::{channel_info as core_channel_info, ChannelInfo as CoreInfo};
|
||||
use strawcore_core::youtube::channel::{
|
||||
channel_info as core_channel_info, channel_videos_continuation as core_channel_continuation,
|
||||
ChannelInfo as CoreInfo,
|
||||
};
|
||||
use strawcore_core::youtube::linkhandler::channel as core_link;
|
||||
|
||||
use crate::error::StrawcoreError;
|
||||
use crate::search::{from_core as search_from_core, SearchItem};
|
||||
use crate::search::{from_core as search_from_core, page_from_core, Page, SearchItem};
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ChannelInfo {
|
||||
|
|
@ -19,6 +22,10 @@ pub struct ChannelInfo {
|
|||
pub description: String,
|
||||
/// Latest videos from the channel (Videos tab, newest first).
|
||||
pub videos: Vec<SearchItem>,
|
||||
/// Token to fetch the next page of the Videos tab via
|
||||
/// `channel_videos_continuation`. null = the channel has no more
|
||||
/// videos (or YT didn't hand out a continuation).
|
||||
pub videos_continuation: Option<String>,
|
||||
}
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
|
|
@ -47,6 +54,23 @@ fn resolve_channel_identifier(
|
|||
})
|
||||
}
|
||||
|
||||
/// Fetch the next page of a channel's Videos tab from a continuation
|
||||
/// token returned on `ChannelInfo.videos_continuation` or a prior Page.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn channel_videos_continuation(token: String) -> Result<Page, StrawcoreError> {
|
||||
log::info!(
|
||||
"strawcore::channel_videos_continuation token_len={}",
|
||||
token.len()
|
||||
);
|
||||
crate::runtime::ensure_initialized();
|
||||
let page = tokio::task::spawn_blocking(move || core_channel_continuation(&token))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
Ok(page_from_core(page))
|
||||
}
|
||||
|
||||
fn map_channel(c: CoreInfo) -> ChannelInfo {
|
||||
let avatar = c.avatars.last().map(|i| i.url().to_string());
|
||||
let banner = c.banners.last().map(|i| i.url().to_string());
|
||||
|
|
@ -59,5 +83,6 @@ fn map_channel(c: CoreInfo) -> ChannelInfo {
|
|||
subscriber_count: c.subscriber_count,
|
||||
description: c.description,
|
||||
videos,
|
||||
videos_continuation: c.videos_continuation,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mod stream;
|
|||
// Re-exports so UniFFI sees the types at the crate root for macro discovery.
|
||||
pub use channel::ChannelInfo;
|
||||
pub use error::StrawcoreError;
|
||||
pub use search::SearchItem;
|
||||
pub use search::{Page, SearchItem};
|
||||
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
|
||||
|
||||
/// Initialize Android logging + the strawcore-core HTTP downloader.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// changes.
|
||||
|
||||
use strawcore_core::stream::StreamInfoItem;
|
||||
use strawcore_core::youtube::continuation::ContinuationPage;
|
||||
use strawcore_core::youtube::linkhandler::search::SearchFilter;
|
||||
use strawcore_core::youtube::search_extractor;
|
||||
|
||||
|
|
@ -26,6 +27,24 @@ pub struct SearchItem {
|
|||
pub upload_date_relative: String,
|
||||
}
|
||||
|
||||
/// A page of results plus the opaque token that fetches the NEXT page.
|
||||
/// `continuation == null` means there are no more results — the Kotlin
|
||||
/// side stops requesting once it sees a null token. Shared by `search`,
|
||||
/// `search_continuation`, and the channel-videos continuation.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Page {
|
||||
pub items: Vec<SearchItem>,
|
||||
pub continuation: Option<String>,
|
||||
}
|
||||
|
||||
/// Map a core ContinuationPage (channel or search) onto the FFI Page.
|
||||
pub(crate) fn page_from_core(page: ContinuationPage) -> Page {
|
||||
Page {
|
||||
items: page.items.into_iter().map(from_core).collect(),
|
||||
continuation: page.continuation,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||
let uploader_url = if item.uploader_url.is_empty() {
|
||||
None
|
||||
|
|
@ -53,7 +72,7 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
|||
}
|
||||
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
||||
pub async fn search(query: String) -> Result<Page, StrawcoreError> {
|
||||
// Don't log the query itself — searches are PII (sometimes
|
||||
// names, sometimes embarrassing) and android_logger emits at
|
||||
// info-level in release builds, which means they'd ride the
|
||||
|
|
@ -72,5 +91,25 @@ pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
|||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
Ok(result.videos.into_iter().map(from_core).collect())
|
||||
// Page 1 carries the first continuation token (None once YT stops
|
||||
// handing them out). The Kotlin SearchViewModel passes it back to
|
||||
// `search_continuation` on scroll.
|
||||
Ok(Page {
|
||||
items: result.videos.into_iter().map(from_core).collect(),
|
||||
continuation: result.continuation_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the next page of search results from a continuation token
|
||||
/// returned by a prior `search` / `search_continuation` Page.
|
||||
#[uniffi::export(async_runtime = "tokio")]
|
||||
pub async fn search_continuation(token: String) -> Result<Page, StrawcoreError> {
|
||||
log::info!("strawcore::search_continuation token_len={}", token.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let page = tokio::task::spawn_blocking(move || search_extractor::search_continuation(&token))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
Ok(page_from_core(page))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.material3.Button
|
||||
|
|
@ -35,6 +36,7 @@ import androidx.compose.material3.OutlinedButton
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -106,7 +108,26 @@ fun ChannelScreen(
|
|||
val filteredVideos = remember(state.videos, hideShorts) {
|
||||
com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = hideShorts)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
// Infinite scroll: when the user nears the end of the loaded rows
|
||||
// and the channel still has a continuation token, pull the next
|
||||
// page. derivedStateOf so this only recomputes on scroll, not on
|
||||
// every recomposition.
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val layout = listState.layoutInfo
|
||||
val total = layout.totalItemsCount
|
||||
val lastVisible = layout.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
total > 0 && lastVisible >= total - 3
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shouldLoadMore, state.videosContinuation, state.loadingMore) {
|
||||
if (shouldLoadMore && state.videosContinuation != null && !state.loadingMore) {
|
||||
vm.loadMore()
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||
contentPadding = rememberBottomContentPadding(),
|
||||
) {
|
||||
|
|
@ -178,6 +199,14 @@ fun ChannelScreen(
|
|||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (state.loadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ data class ChannelUiState(
|
|||
* data while believing it's B.
|
||||
*/
|
||||
val loadedUrl: String? = null,
|
||||
/**
|
||||
* Token for the next page of the Videos tab. Non-null means more
|
||||
* videos can be loaded via [ChannelViewModel.loadMore]; null means
|
||||
* the channel is exhausted (or YT stopped handing out
|
||||
* continuations) and the list is complete.
|
||||
*/
|
||||
val videosContinuation: String? = null,
|
||||
/** True while a [ChannelViewModel.loadMore] fetch is in flight. */
|
||||
val loadingMore: Boolean = false,
|
||||
)
|
||||
|
||||
class ChannelViewModel : ViewModel() {
|
||||
|
|
@ -51,6 +60,12 @@ class ChannelViewModel : ViewModel() {
|
|||
// / MED-1.
|
||||
private var inFlight: Job? = null
|
||||
|
||||
// Tracked separately from inFlight so (a) a channel switch cancels an
|
||||
// in-flight pagination fetch, and (b) loadMore never races the
|
||||
// initial load's job bookkeeping. Cancelled whenever we reset for a
|
||||
// new/rejected channel.
|
||||
private var loadMoreJob: Job? = null
|
||||
|
||||
fun load(channelUrl: String) {
|
||||
// Snapshot _ui once so the two reads agree.
|
||||
val snap = _ui.value
|
||||
|
|
@ -64,6 +79,8 @@ class ChannelViewModel : ViewModel() {
|
|||
if (!isAllowedYtUrl(channelUrl)) {
|
||||
inFlight?.cancel()
|
||||
inFlight = null
|
||||
loadMoreJob?.cancel()
|
||||
loadMoreJob = null
|
||||
_ui.update {
|
||||
ChannelUiState(
|
||||
loading = false,
|
||||
|
|
@ -74,6 +91,8 @@ class ChannelViewModel : ViewModel() {
|
|||
return
|
||||
}
|
||||
inFlight?.cancel()
|
||||
loadMoreJob?.cancel()
|
||||
loadMoreJob = null
|
||||
_ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) }
|
||||
inFlight = viewModelScope.launch {
|
||||
try {
|
||||
|
|
@ -100,6 +119,7 @@ class ChannelViewModel : ViewModel() {
|
|||
avatar = ch.avatar,
|
||||
videos = videos,
|
||||
loadedUrl = channelUrl,
|
||||
videosContinuation = ch.videosContinuation,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
|
|
@ -120,4 +140,63 @@ class ChannelViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the next page of the channel's Videos tab. No-op when there
|
||||
* is no continuation token (list complete), the initial load is
|
||||
* still running, or a page fetch is already in flight. Soft-fails: a
|
||||
* failed page clears the spinner and drops the token so we don't spin
|
||||
* on a broken continuation — the already-loaded videos stay put, no
|
||||
* error banner over a working list.
|
||||
*/
|
||||
fun loadMore() {
|
||||
val snap = _ui.value
|
||||
val token = snap.videosContinuation ?: return
|
||||
if (snap.loading || snap.loadingMore) return
|
||||
val url = snap.loadedUrl ?: return
|
||||
_ui.update { it.copy(loadingMore = true) }
|
||||
loadMoreJob = viewModelScope.launch {
|
||||
try {
|
||||
val page = uniffi.strawcore.channelVideosContinuation(token)
|
||||
// Channel switched out from under us mid-fetch.
|
||||
if (_ui.value.loadedUrl != url) return@launch
|
||||
val newItems = page.items.map { v ->
|
||||
StreamItem(
|
||||
url = v.url,
|
||||
title = v.title.ifBlank { "(no title)" },
|
||||
uploader = v.uploader,
|
||||
uploaderUrl = v.uploaderUrl,
|
||||
thumbnail = v.thumbnail,
|
||||
durationSeconds = v.durationSeconds,
|
||||
viewCount = v.viewCount,
|
||||
uploadDateRelative = v.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
_ui.update { st ->
|
||||
// Fence: channel must be unchanged AND this must still
|
||||
// be the live token (a switch/reload must not get a
|
||||
// stale page spliced in).
|
||||
if (st.loadedUrl != url || st.videosContinuation != token) {
|
||||
return@update st
|
||||
}
|
||||
// De-dup by url — a continuation occasionally repeats a
|
||||
// tail item; appending blind would double-render it.
|
||||
val seen = st.videos.mapTo(HashSet()) { it.url }
|
||||
val fresh = newItems.filter { seen.add(it.url) }
|
||||
st.copy(
|
||||
videos = st.videos + fresh,
|
||||
// Zero net-new (all dups / a looping continuation)
|
||||
// → stop, or the near-end trigger busy-loops on a
|
||||
// dead-end token.
|
||||
videosContinuation = if (fresh.isEmpty()) null else page.continuation,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
if (_ui.value.loadedUrl != url) return@launch
|
||||
_ui.update { it.copy(loadingMore = false, videosContinuation = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
|
@ -31,6 +32,8 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -166,7 +169,25 @@ fun SearchScreen(
|
|||
val filteredResults = remember(state.results, hideShorts) {
|
||||
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
// Infinite scroll: near the end of a live result set with a
|
||||
// continuation token, pull the next page. Cache previews
|
||||
// have a null continuation, so this stays quiet for them.
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val layout = listState.layoutInfo
|
||||
val total = layout.totalItemsCount
|
||||
val lastVisible = layout.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
total > 0 && lastVisible >= total - 3
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shouldLoadMore, state.continuation, state.loadingMore) {
|
||||
if (shouldLoadMore && state.continuation != null && !state.loadingMore) {
|
||||
vm.loadMore()
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = rememberBottomContentPadding(),
|
||||
) {
|
||||
|
|
@ -186,6 +207,14 @@ fun SearchScreen(
|
|||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
if (state.loadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ data class SearchUiState(
|
|||
* show a faint "from cache" hint without blocking the list.
|
||||
*/
|
||||
val fromCache: Boolean = false,
|
||||
/**
|
||||
* Token for the next page of network results. Non-null only for a
|
||||
* live (non-cache) result set with more pages; null for cache
|
||||
* previews and once the last page is reached.
|
||||
*/
|
||||
val continuation: String? = null,
|
||||
/** True while a [SearchViewModel.loadMore] fetch is in flight. */
|
||||
val loadingMore: Boolean = false,
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
|
|
@ -106,6 +114,11 @@ class SearchViewModel : ViewModel() {
|
|||
// submit" intent — only a fresh submit cancels me.
|
||||
private var inFlight: Job? = null
|
||||
|
||||
// Pagination fetch for the current live result set. Cancelled
|
||||
// whenever that set is replaced (new submit, or a cache preview
|
||||
// swaps in) so a late page can't append to stale results.
|
||||
private var loadMoreJob: Job? = null
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
// Clear any prior error state when the user resumes typing —
|
||||
// a failed submit's banner used to persist into the next
|
||||
|
|
@ -115,18 +128,29 @@ class SearchViewModel : ViewModel() {
|
|||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||
val matches = reactiveFilter(q.trim())
|
||||
if (matches.isNotEmpty()) {
|
||||
// Cache preview replaces the live set → kill its pagination
|
||||
// and drop the token (cache views don't paginate).
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
results = matches,
|
||||
fromCache = true,
|
||||
loading = false,
|
||||
continuation = null,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} else if (_ui.value.fromCache) {
|
||||
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(results = emptyList(), fromCache = false, continuation = null)
|
||||
}
|
||||
}
|
||||
} else if (q.isBlank()) {
|
||||
_ui.update { it.copy(results = emptyList(), fromCache = false) }
|
||||
loadMoreJob?.cancel()
|
||||
_ui.update {
|
||||
it.copy(results = emptyList(), fromCache = false, continuation = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +175,9 @@ class SearchViewModel : ViewModel() {
|
|||
// prior coroutine had already advanced past its `ensureActive`
|
||||
// gate by the time the new submit got around to cancelling.
|
||||
inFlight?.cancel()
|
||||
// A fresh submit invalidates any in-flight pagination of the
|
||||
// previous result set; drop its token until page 1 lands.
|
||||
loadMoreJob?.cancel()
|
||||
|
||||
if (cached != null && cached.isNotEmpty()) {
|
||||
_ui.update {
|
||||
|
|
@ -159,6 +186,8 @@ class SearchViewModel : ViewModel() {
|
|||
error = null,
|
||||
results = cached,
|
||||
fromCache = true,
|
||||
continuation = null,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -168,6 +197,8 @@ class SearchViewModel : ViewModel() {
|
|||
error = null,
|
||||
results = emptyList(),
|
||||
fromCache = false,
|
||||
continuation = null,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -176,8 +207,9 @@ class SearchViewModel : ViewModel() {
|
|||
try {
|
||||
// strawcore.search() is suspend on the tokio runtime baked
|
||||
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
||||
val rustItems = uniffi.strawcore.search(q)
|
||||
val items = rustItems.map { r ->
|
||||
// Returns a Page now: items + the first continuation token.
|
||||
val page = uniffi.strawcore.search(q)
|
||||
val items = page.items.map { r ->
|
||||
StreamItem(
|
||||
url = r.url,
|
||||
title = r.title.ifBlank { "(no title)" },
|
||||
|
|
@ -200,6 +232,7 @@ class SearchViewModel : ViewModel() {
|
|||
loading = false,
|
||||
results = items,
|
||||
fromCache = false,
|
||||
continuation = page.continuation,
|
||||
)
|
||||
}
|
||||
// Record AFTER the search succeeds so mistyped queries
|
||||
|
|
@ -233,6 +266,69 @@ class SearchViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the next page of network search results. No-op for cache
|
||||
* previews (which don't paginate), when there's no continuation
|
||||
* token, or while a page fetch / fresh submit is already running.
|
||||
* Cancellation is the fence: a fresh submit or a cache-preview swap
|
||||
* cancels [loadMoreJob], so a late page can never append to a
|
||||
* replaced result set. Soft-fails: a broken page clears the spinner
|
||||
* and drops the token rather than showing an error over a usable
|
||||
* list.
|
||||
*/
|
||||
fun loadMore() {
|
||||
val snap = _ui.value
|
||||
if (snap.fromCache) return
|
||||
val token = snap.continuation ?: return
|
||||
if (snap.loading || snap.loadingMore) return
|
||||
_ui.update { it.copy(loadingMore = true) }
|
||||
loadMoreJob = viewModelScope.launch {
|
||||
try {
|
||||
val page = uniffi.strawcore.searchContinuation(token)
|
||||
// Cancelled mid-fetch (new submit / cache swap) → the
|
||||
// suspend call above already threw; this is the belt for
|
||||
// the gap before the update lands.
|
||||
ensureActive()
|
||||
val newItems = page.items.map { r ->
|
||||
StreamItem(
|
||||
url = r.url,
|
||||
title = r.title.ifBlank { "(no title)" },
|
||||
uploader = r.uploader,
|
||||
uploaderUrl = r.uploaderUrl,
|
||||
thumbnail = r.thumbnail,
|
||||
durationSeconds = r.durationSeconds,
|
||||
viewCount = r.viewCount,
|
||||
uploadDateRelative = r.uploadDateRelative,
|
||||
)
|
||||
}
|
||||
_ui.update { st ->
|
||||
// Fence: only append if THIS token is still the live
|
||||
// one and we haven't flipped to a cache preview. A
|
||||
// result-set swap (new submit / cache preview) must
|
||||
// never get a stale page spliced in. (Cancellation
|
||||
// already guards this; this is belt + braces and makes
|
||||
// the invariant explicit.)
|
||||
if (st.fromCache || st.continuation != token) return@update st
|
||||
// De-dup by url — continuation pages occasionally
|
||||
// repeat a boundary item.
|
||||
val seen = st.results.mapTo(HashSet()) { it.url }
|
||||
val fresh = newItems.filter { seen.add(it.url) }
|
||||
st.copy(
|
||||
results = st.results + fresh,
|
||||
// Zero net-new items (all dups / a looping
|
||||
// continuation) → stop, or the near-end trigger
|
||||
// would busy-loop against a dead-end token.
|
||||
continuation = if (fresh.isEmpty()) null else page.continuation,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
_ui.update { it.copy(loadingMore = false, continuation = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the in-memory `pool` and return items whose title or uploader
|
||||
* contains the query. Case-insensitive, capped at 60 results.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue