mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-11 09:27:03 +01:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user