mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android,ios): stop a crawl from the phones, as the desktop can
The bridge gains stop_crawl(). A running crawl watches for it and returns without an error. Shares already read stay stored, and a queue of new shares stops instead of going on to the next. The activity banner shows Stop while a crawl runs, because listing a large share can take minutes and holds the connection the player wants.
This commit is contained in:
1 parent
6ddbcea429
commit
fdf6b0359b
5 files changed
+97
-11
No files matched your search
@@ -47,6 +47,8 @@ import kotlinx.coroutines.isActive
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/**
|
||||
@@ -118,6 +120,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
private val activities = mutableListOf<Pair<Long, String>>()
|
||||
private var nextActivity = 0L
|
||||
|
||||
/** Crawls running now; main thread only, like [activities]. */
|
||||
private var crawlsRunning = 0
|
||||
|
||||
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||
private var crawlStopped = false
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
mutableState.update {
|
||||
@@ -187,7 +195,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
viewModelScope.launch {
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
@@ -225,6 +233,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||
private suspend fun crawlShares(id: String?) {
|
||||
crawlsRunning++
|
||||
mutableState.update { it.copy(crawling = true) }
|
||||
try {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||
} finally {
|
||||
crawlsRunning--
|
||||
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the crawls running now. Shares already read stay in the library;
|
||||
* the one being read keeps what it had.
|
||||
*/
|
||||
fun stopCrawl() {
|
||||
crawlStopped = true
|
||||
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
private fun showActivity() {
|
||||
val newest = activities.lastOrNull()?.second
|
||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||
@@ -243,7 +272,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
@@ -309,10 +338,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
if (ids.isEmpty()) return
|
||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||
val failed = mutableListOf<String>()
|
||||
crawlStopped = false
|
||||
for (id in ids) {
|
||||
if (crawlStopped) break
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||
busy("Reading $name…") {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
@@ -355,7 +356,8 @@ fun ProtonStreamApp(
|
||||
}
|
||||
ActivityBanner(
|
||||
state.activity,
|
||||
Modifier
|
||||
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
@@ -437,7 +439,7 @@ private fun MiniTransport(
|
||||
* Not dismissible and not timed — it goes when the work does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
||||
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = activity != null,
|
||||
modifier = modifier,
|
||||
@@ -458,7 +460,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
|
||||
// A crawl of a large share can take minutes, and is the viewer's
|
||||
// to call off; what it has read so far stays.
|
||||
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -963,6 +963,8 @@ pub struct AndroidEngine {
|
||||
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
|
||||
/// The whole library as bridge records, behind the catalog's write counter.
|
||||
library_cache: Mutex<Option<CachedLibrary>>,
|
||||
/// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops.
|
||||
crawl_stop: tokio::sync::watch::Sender<u64>,
|
||||
/// Serializes offline publication with share removal cleanup.
|
||||
share_publication: Mutex<()>,
|
||||
/// Advances after every successful share-store mutation. A connection is
|
||||
@@ -1039,6 +1041,7 @@ impl AndroidEngine {
|
||||
connecting: tokio::sync::Mutex::new(()),
|
||||
reusable_clients: Mutex::new(None),
|
||||
library_cache: Mutex::new(None),
|
||||
crawl_stop: tokio::sync::watch::channel(0).0,
|
||||
share_publication: Mutex::new(()),
|
||||
share_generation: AtomicU64::new(0),
|
||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||
@@ -2017,6 +2020,13 @@ impl AndroidEngine {
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Stop every crawl running now. Shares already listed stay stored; the
|
||||
/// one being listed keeps what it had. The stopped `crawl` returns
|
||||
/// without an error, since whoever stopped it knows why.
|
||||
pub fn stop_crawl(&self) {
|
||||
self.crawl_stop.send_modify(|generation| *generation += 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl AndroidEngine {
|
||||
@@ -2517,12 +2527,17 @@ impl AndroidEngine {
|
||||
}
|
||||
|
||||
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
||||
let mut stop = self.crawl_stop.subscribe();
|
||||
stop.borrow_and_update();
|
||||
let connection = self.connection().await?;
|
||||
let targets: Vec<String> = match share_id {
|
||||
Some(id) => vec![id],
|
||||
None => connection.library.share_ids().map(str::to_owned).collect(),
|
||||
};
|
||||
for id in targets {
|
||||
if stop.has_changed().unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
connection
|
||||
.library
|
||||
.refresh_session(&id)
|
||||
@@ -2535,7 +2550,11 @@ impl AndroidEngine {
|
||||
.lock()
|
||||
.all_offline_files()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let nodes = match connection.library.crawl(&id).await {
|
||||
let crawled = tokio::select! {
|
||||
crawled = connection.library.crawl(&id) => crawled,
|
||||
_ = stop.changed() => return Ok(()),
|
||||
};
|
||||
let nodes = match crawled {
|
||||
Ok(nodes) => nodes,
|
||||
Err(error) => {
|
||||
// On the share's row until a crawl succeeds: a link revoked
|
||||
|
||||
@@ -38,6 +38,8 @@ struct WatchSnapshot: Equatable {
|
||||
final class AppModel {
|
||||
var loading = true
|
||||
var refreshing = false
|
||||
/// A crawl is running, which the banner offers to stop.
|
||||
private(set) var crawling = false
|
||||
private(set) var query = ""
|
||||
var titles: [TitleRecord] = []
|
||||
/// The grid: `titles` ordered, each franchise folded into one tile when
|
||||
@@ -68,6 +70,10 @@ final class AppModel {
|
||||
/// Every banner line whose work is still running, oldest first.
|
||||
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||
@ObservationIgnored private var nextActivity = 0
|
||||
/// Crawls running now.
|
||||
@ObservationIgnored private var crawlsRunning = 0
|
||||
/// Set by `stopCrawl`, so a queue of shares stops rather than going on to the next.
|
||||
@ObservationIgnored private var crawlStopped = false
|
||||
|
||||
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||
/// listing, and no upload.
|
||||
@@ -137,6 +143,24 @@ final class AppModel {
|
||||
Task { await crawlNow(shareId) }
|
||||
}
|
||||
|
||||
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
||||
private func crawlShares(_ id: String?) async throws {
|
||||
crawlsRunning += 1
|
||||
crawling = true
|
||||
defer {
|
||||
crawlsRunning -= 1
|
||||
crawling = crawlsRunning > 0
|
||||
}
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
}
|
||||
|
||||
/// Stop the crawls running now. Shares already read stay in the library;
|
||||
/// the one being read keeps what it had.
|
||||
func stopCrawl() {
|
||||
crawlStopped = true
|
||||
NativeRuntime.engine().stopCrawl()
|
||||
}
|
||||
|
||||
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||
/// this returns.
|
||||
func crawlNow(_ shareId: String?) async {
|
||||
@@ -144,7 +168,7 @@ final class AppModel {
|
||||
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||
message = nil
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
try await crawlShares(shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
@@ -235,11 +259,13 @@ final class AppModel {
|
||||
guard !ids.isEmpty else { return }
|
||||
let before = Set(titles.map(\.key))
|
||||
var failed: [String] = []
|
||||
crawlStopped = false
|
||||
for id in ids {
|
||||
if crawlStopped { break }
|
||||
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||
await busy("Reading \(name)…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
failed.append("\(name): \(errorMessage(error))")
|
||||
}
|
||||
@@ -272,7 +298,7 @@ final class AppModel {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
|
||||
@@ -332,13 +332,18 @@ private struct ActivityBanner: View {
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// A crawl of a large share can take minutes, and is the
|
||||
// viewer's to call off; what it has read so far stays.
|
||||
if model.crawling {
|
||||
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||
.padding(.horizontal, 12)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityElement(children: model.crawling ? .contain : .combine)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user