straw: infinite-scroll pagination for channel + search
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
7cccc96d20
commit
21ca8da8d2
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
|
// Used by ChannelScreen (single-channel view) AND
|
||||||
// SubscriptionFeedViewModel (which fans out across all subscriptions).
|
// 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 strawcore_core::youtube::linkhandler::channel as core_link;
|
||||||
|
|
||||||
use crate::error::StrawcoreError;
|
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)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct ChannelInfo {
|
pub struct ChannelInfo {
|
||||||
|
|
@ -19,6 +22,10 @@ pub struct ChannelInfo {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Latest videos from the channel (Videos tab, newest first).
|
/// Latest videos from the channel (Videos tab, newest first).
|
||||||
pub videos: Vec<SearchItem>,
|
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")]
|
#[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 {
|
fn map_channel(c: CoreInfo) -> ChannelInfo {
|
||||||
let avatar = c.avatars.last().map(|i| i.url().to_string());
|
let avatar = c.avatars.last().map(|i| i.url().to_string());
|
||||||
let banner = c.banners.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,
|
subscriber_count: c.subscriber_count,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
videos,
|
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.
|
// Re-exports so UniFFI sees the types at the crate root for macro discovery.
|
||||||
pub use channel::ChannelInfo;
|
pub use channel::ChannelInfo;
|
||||||
pub use error::StrawcoreError;
|
pub use error::StrawcoreError;
|
||||||
pub use search::SearchItem;
|
pub use search::{Page, SearchItem};
|
||||||
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
|
pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem};
|
||||||
|
|
||||||
/// Initialize Android logging + the strawcore-core HTTP downloader.
|
/// Initialize Android logging + the strawcore-core HTTP downloader.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// changes.
|
// changes.
|
||||||
|
|
||||||
use strawcore_core::stream::StreamInfoItem;
|
use strawcore_core::stream::StreamInfoItem;
|
||||||
|
use strawcore_core::youtube::continuation::ContinuationPage;
|
||||||
use strawcore_core::youtube::linkhandler::search::SearchFilter;
|
use strawcore_core::youtube::linkhandler::search::SearchFilter;
|
||||||
use strawcore_core::youtube::search_extractor;
|
use strawcore_core::youtube::search_extractor;
|
||||||
|
|
||||||
|
|
@ -26,6 +27,24 @@ pub struct SearchItem {
|
||||||
pub upload_date_relative: String,
|
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 {
|
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
let uploader_url = if item.uploader_url.is_empty() {
|
let uploader_url = if item.uploader_url.is_empty() {
|
||||||
None
|
None
|
||||||
|
|
@ -53,7 +72,7 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export(async_runtime = "tokio")]
|
#[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
|
// Don't log the query itself — searches are PII (sometimes
|
||||||
// names, sometimes embarrassing) and android_logger emits at
|
// names, sometimes embarrassing) and android_logger emits at
|
||||||
// info-level in release builds, which means they'd ride the
|
// 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 {
|
.map_err(|e| StrawcoreError::Extractor {
|
||||||
msg: format!("join: {e}"),
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
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.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
|
@ -35,6 +36,7 @@ import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -106,7 +108,26 @@ fun ChannelScreen(
|
||||||
val filteredVideos = remember(state.videos, hideShorts) {
|
val filteredVideos = remember(state.videos, hideShorts) {
|
||||||
com.sulkta.straw.util.applyContentFilters(state.videos, hideShorts = 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(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
contentPadding = rememberBottomContentPadding(),
|
contentPadding = rememberBottomContentPadding(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -178,6 +199,14 @@ fun ChannelScreen(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
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.
|
* data while believing it's B.
|
||||||
*/
|
*/
|
||||||
val loadedUrl: String? = null,
|
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() {
|
class ChannelViewModel : ViewModel() {
|
||||||
|
|
@ -51,6 +60,12 @@ class ChannelViewModel : ViewModel() {
|
||||||
// / MED-1.
|
// / MED-1.
|
||||||
private var inFlight: Job? = null
|
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) {
|
fun load(channelUrl: String) {
|
||||||
// Snapshot _ui once so the two reads agree.
|
// Snapshot _ui once so the two reads agree.
|
||||||
val snap = _ui.value
|
val snap = _ui.value
|
||||||
|
|
@ -64,6 +79,8 @@ class ChannelViewModel : ViewModel() {
|
||||||
if (!isAllowedYtUrl(channelUrl)) {
|
if (!isAllowedYtUrl(channelUrl)) {
|
||||||
inFlight?.cancel()
|
inFlight?.cancel()
|
||||||
inFlight = null
|
inFlight = null
|
||||||
|
loadMoreJob?.cancel()
|
||||||
|
loadMoreJob = null
|
||||||
_ui.update {
|
_ui.update {
|
||||||
ChannelUiState(
|
ChannelUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
|
|
@ -74,6 +91,8 @@ class ChannelViewModel : ViewModel() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inFlight?.cancel()
|
inFlight?.cancel()
|
||||||
|
loadMoreJob?.cancel()
|
||||||
|
loadMoreJob = null
|
||||||
_ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) }
|
_ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) }
|
||||||
inFlight = viewModelScope.launch {
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -100,6 +119,7 @@ class ChannelViewModel : ViewModel() {
|
||||||
avatar = ch.avatar,
|
avatar = ch.avatar,
|
||||||
videos = videos,
|
videos = videos,
|
||||||
loadedUrl = channelUrl,
|
loadedUrl = channelUrl,
|
||||||
|
videosContinuation = ch.videosContinuation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} 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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
|
@ -31,6 +32,8 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -166,7 +169,25 @@ fun SearchScreen(
|
||||||
val filteredResults = remember(state.results, hideShorts) {
|
val filteredResults = remember(state.results, hideShorts) {
|
||||||
com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = 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(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = rememberBottomContentPadding(),
|
contentPadding = rememberBottomContentPadding(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -186,6 +207,14 @@ fun SearchScreen(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
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.
|
* show a faint "from cache" hint without blocking the list.
|
||||||
*/
|
*/
|
||||||
val fromCache: Boolean = false,
|
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
|
@kotlinx.serialization.Serializable
|
||||||
|
|
@ -106,6 +114,11 @@ class SearchViewModel : ViewModel() {
|
||||||
// submit" intent — only a fresh submit cancels me.
|
// submit" intent — only a fresh submit cancels me.
|
||||||
private var inFlight: Job? = null
|
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) {
|
fun onQueryChange(q: String) {
|
||||||
// Clear any prior error state when the user resumes typing —
|
// Clear any prior error state when the user resumes typing —
|
||||||
// a failed submit's banner used to persist into the next
|
// 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) {
|
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||||
val matches = reactiveFilter(q.trim())
|
val matches = reactiveFilter(q.trim())
|
||||||
if (matches.isNotEmpty()) {
|
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 {
|
_ui.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
results = matches,
|
results = matches,
|
||||||
fromCache = true,
|
fromCache = true,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
continuation = null,
|
||||||
|
loadingMore = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (_ui.value.fromCache) {
|
} 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()) {
|
} 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`
|
// prior coroutine had already advanced past its `ensureActive`
|
||||||
// gate by the time the new submit got around to cancelling.
|
// gate by the time the new submit got around to cancelling.
|
||||||
inFlight?.cancel()
|
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()) {
|
if (cached != null && cached.isNotEmpty()) {
|
||||||
_ui.update {
|
_ui.update {
|
||||||
|
|
@ -159,6 +186,8 @@ class SearchViewModel : ViewModel() {
|
||||||
error = null,
|
error = null,
|
||||||
results = cached,
|
results = cached,
|
||||||
fromCache = true,
|
fromCache = true,
|
||||||
|
continuation = null,
|
||||||
|
loadingMore = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -168,6 +197,8 @@ class SearchViewModel : ViewModel() {
|
||||||
error = null,
|
error = null,
|
||||||
results = emptyList(),
|
results = emptyList(),
|
||||||
fromCache = false,
|
fromCache = false,
|
||||||
|
continuation = null,
|
||||||
|
loadingMore = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,8 +207,9 @@ class SearchViewModel : ViewModel() {
|
||||||
try {
|
try {
|
||||||
// strawcore.search() is suspend on the tokio runtime baked
|
// strawcore.search() is suspend on the tokio runtime baked
|
||||||
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
// into libstrawcore.so — no Dispatchers.IO wrap needed.
|
||||||
val rustItems = uniffi.strawcore.search(q)
|
// Returns a Page now: items + the first continuation token.
|
||||||
val items = rustItems.map { r ->
|
val page = uniffi.strawcore.search(q)
|
||||||
|
val items = page.items.map { r ->
|
||||||
StreamItem(
|
StreamItem(
|
||||||
url = r.url,
|
url = r.url,
|
||||||
title = r.title.ifBlank { "(no title)" },
|
title = r.title.ifBlank { "(no title)" },
|
||||||
|
|
@ -200,6 +232,7 @@ class SearchViewModel : ViewModel() {
|
||||||
loading = false,
|
loading = false,
|
||||||
results = items,
|
results = items,
|
||||||
fromCache = false,
|
fromCache = false,
|
||||||
|
continuation = page.continuation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Record AFTER the search succeeds so mistyped queries
|
// 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
|
* Walk the in-memory `pool` and return items whose title or uploader
|
||||||
* contains the query. Case-insensitive, capped at 60 results.
|
* contains the query. Case-insensitive, capped at 60 results.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue