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.
115 lines
4.2 KiB
Rust
115 lines
4.2 KiB
Rust
// Phase 7 — search via Sulkta-Coop/strawcore-core. Exposed to Kotlin
|
|
// as a suspend fun. SearchItem field shape is unchanged from Phase U-2
|
|
// so Kotlin callers (SearchViewModel) keep working with no code
|
|
// 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;
|
|
|
|
use crate::error::StrawcoreError;
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct SearchItem {
|
|
pub url: String,
|
|
pub title: String,
|
|
pub uploader: String,
|
|
pub uploader_url: 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,
|
|
}
|
|
|
|
/// 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
|
|
} else {
|
|
Some(item.uploader_url)
|
|
};
|
|
let thumbnail = item
|
|
.thumbnails
|
|
.last()
|
|
.map(|i| i.url().to_string());
|
|
SearchItem {
|
|
url: item.url,
|
|
title: item.name,
|
|
uploader: item.uploader_name,
|
|
uploader_url,
|
|
thumbnail,
|
|
duration_seconds: item.duration_seconds,
|
|
view_count: if item.view_count < 0 {
|
|
0
|
|
} else {
|
|
item.view_count
|
|
},
|
|
upload_date_relative: item.upload_date_relative,
|
|
}
|
|
}
|
|
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
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
|
|
// Settings → Export Logs path straight into a user's chat. Log
|
|
// shape, not content.
|
|
log::info!("strawcore::search query_len={}", query.len());
|
|
// ensure_initialized was only wired into
|
|
// init_logging() so the 5s-backoff retry path never fired from
|
|
// the hot entry points. Now every extractor entry re-asserts
|
|
// — cheap when INITIALIZED is true (single Acquire load).
|
|
crate::runtime::ensure_initialized();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
search_extractor::search(&query, SearchFilter::Videos)
|
|
})
|
|
.await
|
|
.map_err(|e| StrawcoreError::Extractor {
|
|
msg: format!("join: {e}"),
|
|
})??;
|
|
// 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))
|
|
}
|