feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it

Sync carried only watch positions, so a second device started empty. The
per-installation history file now also carries per-key registers
(pstr-core::profile): every share with its link and custom password, the
playback, appearance and metadata settings, the TMDB key, hand-picked
matches and per-title track choices. Newer updated_at wins per key, a
removal travels as an empty register, and a first sync stamps at 0 so a
fresh install takes the library the other devices have instead of
overwriting it with defaults. Volume, mute and the device-only settings
(cache budget, Wi-Fi only, background audio, hardware decoding) stay out.
The secrets live only in the end-to-end encrypted Drive file, never in
shares.json. FORMAT_VERSION stays 1; an older build ignores the registers.

Adding a share stored it before opening the link, so a first failure left a
broken entry and every retry said "already configured". add_verified opens
the link first and stores nothing on failure, and only the new share is
crawled afterwards, so another broken share cannot fail the add.

Android drops picture-in-picture: the window sized the video wrongly and
background audio already covers leaving the app.

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
This commit is contained in:
nvrl committed 2026-10-09 03:15:39 +02:00
1 parent dc70cafbb7
commit 8e42906a7f
37 files changed
+2018 -594

No files matched your search

@@ -143,8 +143,9 @@ internal fun AccountCard(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text(
"Optional. Signed in, watch history is kept in your own Drive so every device " +
"resumes where another left off, and folders of your Drive can join the library.",
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
"Drive, so every device shows the same library and resumes where another left off, " +
"and folders of your Drive can join the library.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -156,8 +157,8 @@ internal fun AccountCard(
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Watch history did not sync: $it" }
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
account.syncError?.let { return "Did not sync: $it" }
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when {
minutes < 1 -> "just now"
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord
@@ -38,6 +39,7 @@ import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import uniffi.pstr_android.SyncRecord
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -64,6 +66,12 @@ data class AppUiState(
),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null,
/**
* What is running in the background right now, said in a banner for as
* long as it runs: an add, a crawl, a match, a sync. Without it a
* minute-long crawl looked like a tap that did nothing.
*/
val activity: String? = null,
/** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
@@ -99,6 +107,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
/** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false
init {
if (!NativeRuntime.tlsReady) {
@@ -107,6 +117,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
reload()
catchUp()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
@@ -116,6 +127,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// and no upload.
while (isActive) {
syncWatchHistory()
// A sync WatchSyncWorker ran while the app was away may have
// brought shares this one has not crawled.
catchUp()
delay(SYNC_INTERVAL_MS)
}
}
@@ -144,14 +158,29 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun refresh() {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
busy("Refreshing the library…") {
mutableState.update { it.copy(message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reload()
}
}
/**
* Say [what] in the activity banner while [work] runs. Nested work keeps
* the outer line once the inner one is done.
*/
private suspend fun busy(what: String, work: suspend () -> Unit) {
val outer = mutableState.value.activity
mutableState.update { it.copy(activity = what, refreshing = true) }
try {
work()
} finally {
mutableState.update { it.copy(activity = outer, refreshing = outer != null) }
}
}
/**
* Recrawl one share rather than the whole library.
*
@@ -162,10 +191,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
*/
fun refreshShare(id: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
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) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reload()
}
}
@@ -179,26 +210,80 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* changed.
*/
fun matchTitles(force: Boolean) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
viewModelScope.launch { match(force) }
}
private suspend fun match(force: Boolean) {
busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
reloadNow()
}
/**
* Add a link, and return why it was refused, if it was.
*
* The link is opened before anything is stored, so the form can stay up
* with the reason under it and a retry is a retry — not "already in the
* library" for a share that never opened. Only the new share is crawled:
* another share that fails to open must not make this one look broken.
*/
suspend fun addShare(name: String, url: String, password: String?): String? {
val share = runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
}
}.getOrElse { return it.message ?: "That link could not be added" }
reload()
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
return null
}
/**
* Crawl shares that are new here — added on this device or by sync — then
* match them when enrichment is on.
*/
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>()
for (id in ids) {
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { failed += "$name: ${it.message}" }
}
}
reloadNow()
if (failed.isNotEmpty()) {
mutableState.update { it.copy(message = failed.joinToString("\n")) }
return
}
if (mutableState.value.metadataSettings.enabled) match(force = false)
if (announce) {
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
}
}
fun addShare(name: String, url: String, password: String?) {
/**
* Crawl shares the catalog has nothing of: brought by a sync that ran in
* [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never
* finished.
*/
fun catchUp() {
if (catchingUp) return
catchingUp = true
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
engine.crawl(null)
}
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload()
try {
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
.getOrDefault(emptyList())
crawlAdded(ids, announce = false)
} finally {
catchingUp = false
}
}
}
@@ -354,7 +439,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
syncError = null,
)
}
if (report.applied > 0u) reload()
takeSynced(report)
true
},
onFailure = { error ->
@@ -368,6 +453,33 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
)
}
/**
* What another device changed: its shares crawled or dropped here, its
* settings repainted.
*/
private suspend fun takeSynced(report: SyncRecord) {
if (report.sharesRemoved.isNotEmpty()) {
withContext(Dispatchers.IO) {
val downloads = DownloadStateStore(appContext)
report.sharesRemoved.forEach { id ->
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
downloads.removeShare(id)
}
}
}
if (report.settingsChanged) AppearanceState.reload()
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
reloadNow()
}
if (report.sharesAdded.isNotEmpty()) {
crawlAdded(report.sharesAdded, announce = false)
val count = report.sharesAdded.size
mutableState.update {
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
}
}
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
@@ -376,16 +488,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true) }
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val share = engine.addAccountFolder(name, volumeId, linkId)
engine.crawl(share.id)
}
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
}.onSuccess { share ->
reload()
crawlAdded(listOf(share.id), announce = true)
}.onFailure(::reportError)
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
@@ -487,22 +595,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
}
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false) else null
/**
* Store the enrichment settings, and return why not, if they were not.
*
* The page shows the new settings as soon as they are stored; the match
* run that follows can take minutes and says so in the activity banner.
*/
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
val stored = runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
}
}.onSuccess { summary ->
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError)
reload()
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
engine.metadataSettings()
}
}.getOrElse { return it.message ?: "The settings could not be saved" }
mutableState.update { it.copy(metadataSettings = stored) }
reloadNow()
if (enabled) viewModelScope.launch { match(force = false) }
return null
}
fun reloadAfterMetadataChange() = reload()
@@ -629,40 +742,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
private fun reload() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
)
}
}.onSuccess { result ->
mutableState.update { it.copy(
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
}
viewModelScope.launch { reloadNow() }
}
/** [reload], for a caller that goes on to read what it loaded. */
private suspend fun reloadNow() {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded(
engine.shares(),
engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
)
}
}.onSuccess { result ->
mutableState.update { it.copy(
loading = false,
shares = result.shares,
titles = result.titles,
arrangement = result.arrangement,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
}
private suspend fun reloadLibrary(query: String) {
@@ -1,5 +1,16 @@
package io.narl.protonstream.ui
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.AnimatedVisibility
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -98,7 +109,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@Composable
fun ProtonStreamApp(
playerHost: LibmpvHost? = null,
inPictureInPicture: Boolean = false,
shareLink: String? = null,
onShareLinkTaken: () -> Unit = {},
model: AppViewModel = viewModel(
@@ -327,22 +337,27 @@ fun ProtonStreamApp(
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
)
}
ActivityBanner(
state.activity,
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp),
)
}
}
}
// Drawn over the scaffold rather than instead of it. The player owns the
// whole window while it is up — it is a screen, not a dialog over one, which
// is what lets it draw into the cutout and hand the right window to
// Picture-in-Picture — but the page behind it stays composed, so leaving the
// player finds the title page where it was rather than rebuilt from the top.
// is what lets it draw into the cutout — but the page behind it stays
// composed, so leaving the player finds the title page where it was rather
// than rebuilt from the top.
playing?.let { request ->
PlayerScreen(
title = request.title,
episodes = request.episodes,
index = request.index,
host = playerHost,
inPictureInPicture = inPictureInPicture,
minimized = playerMinimized,
onIndexChange = { playingIndex = it },
onSaveProgress = model::saveProgress,
@@ -400,3 +415,34 @@ private fun MiniTransport(
}
}
}
/**
* What the app is busy with, for as long as it is: a spinner and one line.
* Not dismissible and not timed — it goes when the work does.
*/
@Composable
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = activity != null,
modifier = modifier,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
// Held through the exit, so the line does not blank before it fades.
var shown by remember { mutableStateOf(activity.orEmpty()) }
if (activity != null) shown = activity
Row(
Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
}
}
}
@@ -86,7 +86,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
@Composable
internal fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
/** Resolves to why the settings were not stored, or null once they are. */
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
onMatchAgain: () -> Unit,
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
@@ -327,10 +328,7 @@ internal fun SettingsScreen(
MetadataSettingsDialog(
current = state.metadataSettings,
onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key ->
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
onSave = onSaveMetadata,
)
}
}
@@ -411,14 +409,18 @@ private fun SettingAction(
private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
) {
var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") }
var saving by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") },
text = {
@@ -447,15 +449,25 @@ private fun MetadataSettingsDialog(
singleLine = true,
)
}
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
) { Text("Save") }
enabled = !needsKey && !saving,
onClick = {
saving = true
refusal = null
scope.launch {
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
saving = false
if (refusal == null) onDismiss()
}
},
) { Text(if (saving) "Saving…" else "Save") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
)
}
@@ -1,5 +1,10 @@
package io.narl.protonstream.ui
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -50,7 +55,8 @@ import androidx.compose.ui.graphics.Color
@Composable
internal fun SharesScreen(
shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit,
/** Resolves to why the link was refused, or null once it is added. */
onAdd: suspend (String, String, String?) -> String?,
onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
@@ -277,17 +283,25 @@ private fun RepairShareDialog(
)
}
/**
* Add a link. The form stays up while the link is opened, and with the reason
* under it when the link is refused, so fixing a typo is a retry rather than
* starting over.
*/
@Composable
private fun AddShareDialog(
initialUrl: String,
onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit,
onAdd: suspend (String, String, String?) -> String?,
) {
var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
@@ -312,14 +326,30 @@ private fun AddShareDialog(
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
if (adding) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Opening the link…")
}
} else {
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
enabled = name.isNotBlank() && url.isNotBlank(),
) { Text("Add") }
onClick = {
adding = true
refusal = null
scope.launch {
refusal = onAdd(name.trim(), url.trim(), password)
adding = false
if (refusal == null) onDismiss()
}
},
enabled = name.isNotBlank() && url.isNotBlank() && !adding,
) { Text(if (adding) "Adding…" else "Add") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
)
}
@@ -56,6 +56,12 @@ object AppearanceState {
.onSuccess { current.value = it }
}
/** Read the stored choice again, after sync rewrote it. */
suspend fun reload() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette