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(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
|
/** A crawl is running, which the banner offers to stop. */
|
||||||
|
val crawling: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val titles: List<TitleRecord> = emptyList(),
|
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 val activities = mutableListOf<Pair<Long, String>>()
|
||||||
private var nextActivity = 0L
|
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 {
|
init {
|
||||||
if (!NativeRuntime.tlsReady) {
|
if (!NativeRuntime.tlsReady) {
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
@@ -187,7 +195,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
busy("Refreshing the library…") {
|
busy("Refreshing the library…") {
|
||||||
mutableState.update { it.copy(message = null) }
|
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) } }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
reloadNow()
|
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() {
|
private fun showActivity() {
|
||||||
val newest = activities.lastOrNull()?.second
|
val newest = activities.lastOrNull()?.second
|
||||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
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"
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||||
busy("Reading $name…") {
|
busy("Reading $name…") {
|
||||||
mutableState.update { it.copy(message = null) }
|
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) } }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
reloadNow()
|
reloadNow()
|
||||||
@@ -309,10 +338,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return
|
||||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||||
val failed = mutableListOf<String>()
|
val failed = mutableListOf<String>()
|
||||||
|
crawlStopped = false
|
||||||
for (id in ids) {
|
for (id in ids) {
|
||||||
|
if (crawlStopped) break
|
||||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||||
busy("Reading $name…") {
|
busy("Reading $name…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
runCatching { crawlShares(id) }
|
||||||
.onFailure { failed += "$name: ${it.message}" }
|
.onFailure { failed += "$name: ${it.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
@@ -355,7 +356,8 @@ fun ProtonStreamApp(
|
|||||||
}
|
}
|
||||||
ActivityBanner(
|
ActivityBanner(
|
||||||
state.activity,
|
state.activity,
|
||||||
Modifier
|
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||||
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||||
)
|
)
|
||||||
@@ -437,7 +439,7 @@ private fun MiniTransport(
|
|||||||
* Not dismissible and not timed — it goes when the work does.
|
* Not dismissible and not timed — it goes when the work does.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = activity != null,
|
visible = activity != null,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -458,7 +460,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
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>>>,
|
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
|
||||||
/// The whole library as bridge records, behind the catalog's write counter.
|
/// The whole library as bridge records, behind the catalog's write counter.
|
||||||
library_cache: Mutex<Option<CachedLibrary>>,
|
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.
|
/// Serializes offline publication with share removal cleanup.
|
||||||
share_publication: Mutex<()>,
|
share_publication: Mutex<()>,
|
||||||
/// Advances after every successful share-store mutation. A connection is
|
/// Advances after every successful share-store mutation. A connection is
|
||||||
@@ -1039,6 +1041,7 @@ impl AndroidEngine {
|
|||||||
connecting: tokio::sync::Mutex::new(()),
|
connecting: tokio::sync::Mutex::new(()),
|
||||||
reusable_clients: Mutex::new(None),
|
reusable_clients: Mutex::new(None),
|
||||||
library_cache: Mutex::new(None),
|
library_cache: Mutex::new(None),
|
||||||
|
crawl_stop: tokio::sync::watch::channel(0).0,
|
||||||
share_publication: Mutex::new(()),
|
share_publication: Mutex::new(()),
|
||||||
share_generation: AtomicU64::new(0),
|
share_generation: AtomicU64::new(0),
|
||||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||||
@@ -2017,6 +2020,13 @@ impl AndroidEngine {
|
|||||||
.await
|
.await
|
||||||
.map_err(BridgeError::from_display)?
|
.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 {
|
impl AndroidEngine {
|
||||||
@@ -2517,12 +2527,17 @@ impl AndroidEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
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 connection = self.connection().await?;
|
||||||
let targets: Vec<String> = match share_id {
|
let targets: Vec<String> = match share_id {
|
||||||
Some(id) => vec![id],
|
Some(id) => vec![id],
|
||||||
None => connection.library.share_ids().map(str::to_owned).collect(),
|
None => connection.library.share_ids().map(str::to_owned).collect(),
|
||||||
};
|
};
|
||||||
for id in targets {
|
for id in targets {
|
||||||
|
if stop.has_changed().unwrap_or(false) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
connection
|
connection
|
||||||
.library
|
.library
|
||||||
.refresh_session(&id)
|
.refresh_session(&id)
|
||||||
@@ -2535,7 +2550,11 @@ impl AndroidEngine {
|
|||||||
.lock()
|
.lock()
|
||||||
.all_offline_files()
|
.all_offline_files()
|
||||||
.map_err(BridgeError::from_display)?;
|
.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,
|
Ok(nodes) => nodes,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// On the share's row until a crawl succeeds: a link revoked
|
// On the share's row until a crawl succeeds: a link revoked
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ struct WatchSnapshot: Equatable {
|
|||||||
final class AppModel {
|
final class AppModel {
|
||||||
var loading = true
|
var loading = true
|
||||||
var refreshing = false
|
var refreshing = false
|
||||||
|
/// A crawl is running, which the banner offers to stop.
|
||||||
|
private(set) var crawling = false
|
||||||
private(set) var query = ""
|
private(set) var query = ""
|
||||||
var titles: [TitleRecord] = []
|
var titles: [TitleRecord] = []
|
||||||
/// The grid: `titles` ordered, each franchise folded into one tile when
|
/// 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.
|
/// Every banner line whose work is still running, oldest first.
|
||||||
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||||
@ObservationIgnored private var nextActivity = 0
|
@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
|
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||||
/// listing, and no upload.
|
/// listing, and no upload.
|
||||||
@@ -137,6 +143,24 @@ final class AppModel {
|
|||||||
Task { await crawlNow(shareId) }
|
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
|
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||||
/// this returns.
|
/// this returns.
|
||||||
func crawlNow(_ shareId: String?) async {
|
func crawlNow(_ shareId: String?) async {
|
||||||
@@ -144,7 +168,7 @@ final class AppModel {
|
|||||||
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||||
message = nil
|
message = nil
|
||||||
do {
|
do {
|
||||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
try await crawlShares(shareId)
|
||||||
} catch {
|
} catch {
|
||||||
message = errorMessage(error)
|
message = errorMessage(error)
|
||||||
}
|
}
|
||||||
@@ -235,11 +259,13 @@ final class AppModel {
|
|||||||
guard !ids.isEmpty else { return }
|
guard !ids.isEmpty else { return }
|
||||||
let before = Set(titles.map(\.key))
|
let before = Set(titles.map(\.key))
|
||||||
var failed: [String] = []
|
var failed: [String] = []
|
||||||
|
crawlStopped = false
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if crawlStopped { break }
|
||||||
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||||
await busy("Reading \(name)…") {
|
await busy("Reading \(name)…") {
|
||||||
do {
|
do {
|
||||||
try await NativeRuntime.engine().crawl(shareId: id)
|
try await crawlShares(id)
|
||||||
} catch {
|
} catch {
|
||||||
failed.append("\(name): \(errorMessage(error))")
|
failed.append("\(name): \(errorMessage(error))")
|
||||||
}
|
}
|
||||||
@@ -272,7 +298,7 @@ final class AppModel {
|
|||||||
do {
|
do {
|
||||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
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 run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||||
try await NativeRuntime.engine().crawl(shareId: id)
|
try await crawlShares(id)
|
||||||
} catch {
|
} catch {
|
||||||
reportError(error)
|
reportError(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,13 +332,18 @@ private struct ActivityBanner: View {
|
|||||||
.foregroundStyle(scheme.onSurface)
|
.foregroundStyle(scheme.onSurface)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: model.crawling ? .contain : .combine)
|
||||||
.accessibilityAddTraits(.updatesFrequently)
|
.accessibilityAddTraits(.updatesFrequently)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user