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