straw: infinite-scroll pagination for channel + search
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:
Cobb 2026-06-19 18:21:42 -07:00
parent 6f2ae831cc
commit 4dfb2e1450
8 changed files with 2670 additions and 9 deletions

2364
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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