From 8e42906a7fbf0ab30f519a92dee85c8095ead3fe Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 03:15:39 +0200 Subject: [PATCH] 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. --- android/app/src/main/AndroidManifest.xml | 1 - .../java/io/narl/protonstream/MainActivity.kt | 153 ---- .../protonstream/playback/NativeMpvHost.kt | 10 +- .../protonstream/playback/PlaybackService.kt | 2 +- .../protonstream/playback/PlayerScreen.kt | 31 +- .../java/io/narl/protonstream/ui/AccountUi.kt | 9 +- .../io/narl/protonstream/ui/AppViewModel.kt | 274 ++++-- .../narl/protonstream/ui/ProtonStreamApp.kt | 56 +- .../io/narl/protonstream/ui/SettingsScreen.kt | 34 +- .../io/narl/protonstream/ui/SharesScreen.kt | 44 +- .../io/narl/protonstream/ui/theme/Theme.kt | 6 + .../io/narl/protonstream/ui/AccountUiTest.kt | 4 +- .../io/narl/protonstream/ui/ScreenshotTest.kt | 8 +- crates/pstr-android/src/lib.rs | 192 ++++- crates/pstr-app/src/app.rs | 4 + crates/pstr-app/src/engine.rs | 95 +- crates/pstr-app/src/ui/account.rs | 13 +- crates/pstr-cli/src/main.rs | 27 +- crates/pstr-core/src/lib.rs | 1 + crates/pstr-core/src/profile.rs | 808 ++++++++++++++++++ crates/pstr-core/src/shares.rs | 88 +- crates/pstr-core/src/sync.rs | 225 +++-- docs/ANDROID.md | 10 +- docs/ARCHITECTURE.md | 15 +- docs/IOS.md | 2 +- docs/TESTING.md | 7 +- ios/ProtonStream/App/AppModel.swift | 244 ++++-- ios/ProtonStream/App/ProtonStreamApp.swift | 36 +- ios/ProtonStream/Player/MpvPlayer.swift | 3 + ios/ProtonStream/Player/PlayerView.swift | 22 +- ios/ProtonStream/UI/AccountUI.swift | 6 +- ios/ProtonStream/UI/SettingsView.swift | 91 +- ios/ProtonStream/UI/SharesView.swift | 39 +- ios/ProtonStream/UI/Theme/Theme.swift | 7 + ios/ProtonStream/UI/TitleView.swift | 24 +- ios/ProtonStreamTests/UiLogicTests.swift | 4 +- scripts/android-acceptance.py | 17 - 37 files changed, 2018 insertions(+), 594 deletions(-) create mode 100644 crates/pstr-core/src/profile.rs diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8750b8c..9bfb232 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,6 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden" android:exported="true" android:launchMode="singleTask" - android:supportsPictureInPicture="true" android:resizeableActivity="true"> diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt index 29f53a6..64a57f6 100644 --- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt +++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt @@ -1,35 +1,23 @@ package io.narl.protonstream import android.Manifest -import android.app.PendingIntent -import android.app.PictureInPictureParams -import android.app.RemoteAction -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.ServiceConnection import android.content.pm.PackageManager -import android.content.res.Configuration -import android.graphics.Rect -import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.os.IBinder -import android.util.Rational import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshotFlow -import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import io.narl.protonstream.native.NativeRuntime -import io.narl.protonstream.playback.MpvPlaybackState import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.settings.SettingsStore @@ -37,18 +25,13 @@ import io.narl.protonstream.sync.WatchSyncWorker import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.ProtonStreamTheme -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import uniffi.pstr_android.PaletteRecord class MainActivity : ComponentActivity() { private val playerHost = mutableStateOf(null) - private val inPictureInPicture = mutableStateOf(false) /** A share link handed over by a browser or the share sheet, until the Add form takes it. */ private val incomingShareLink = mutableStateOf(null) @@ -69,30 +52,10 @@ class MainActivity : ComponentActivity() { } } - /** - * The transport behind the buttons Android draws over the PiP window. - * - * A broadcast rather than a service intent: PiP is one of the states in - * which a background service start is refused, and the receiver is - * registered for as long as the activity that owns the window exists. - */ - private val pictureInPictureControls = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val host = playerHost.value ?: return - when (intent?.getStringExtra(EXTRA_CONTROL)) { - CONTROL_PLAY -> host.setPaused(false) - CONTROL_PAUSE -> host.setPaused(true) - CONTROL_NEXT -> host.onSkipNext?.invoke() - CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke() - } - } - } - private val notificationPermission = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } - @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Before anything draws. The palette decides the window's own @@ -116,34 +79,16 @@ class MainActivity : ComponentActivity() { playbackConnection, Context.BIND_AUTO_CREATE, ) - ContextCompat.registerReceiver( - this, - pictureInPictureControls, - IntentFilter(ACTION_PIP_CONTROL), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - // Auto-enter has to be armed *before* the gesture that would use it: - // building the params inside onUserLeaveHint is too late for the swipe - // home that gesture navigation sends, which never calls it. Re-register - // whenever the picture's shape or the transport's does. - lifecycleScope.launch { - snapshotFlow { playerHost.value } - .flatMapLatest { host -> host?.state ?: flowOf(null) } - .map { state -> PictureInPictureShape.of(state) } - .distinctUntilChanged() - .collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } } - } setContent { ProtonStreamTheme { ProtonStreamApp( playerHost.value, - inPictureInPicture.value, shareLink = incomingShareLink.value, onShareLinkTaken = { incomingShareLink.value = null }, ) @@ -178,27 +123,7 @@ class MainActivity : ComponentActivity() { intent.shareLink()?.let { incomingShareLink.value = it } } - override fun onUserLeaveHint() { - super.onUserLeaveHint() - // The backstop for launchers that still send this instead of arming - // auto-enter. Entering twice is not an error; not entering at all is. - if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) { - runCatching { - enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params()) - } - } - } - - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - // The window is now a thumbnail with its own controls. Anything the - // player would draw over it — the transport, the title bar, the skip - // offer — is unreadable at that size and covers the picture. - inPictureInPicture.value = isInPictureInPictureMode - } - override fun onDestroy() { - runCatching { unregisterReceiver(pictureInPictureControls) } if (bound) { unbindService(playbackConnection) bound = false @@ -213,87 +138,9 @@ class MainActivity : ComponentActivity() { if (!isChangingConfigurations) WatchSyncWorker.enqueue(this) if ( !isChangingConfigurations && - !isInPictureInPictureMode && !SettingsStore(this).backgroundAudio ) { playerHost.value?.stop() } } - - /** Everything the PiP window's shape depends on, and nothing that polls. */ - private data class PictureInPictureShape( - val active: Boolean, - val paused: Boolean, - val width: Int, - val height: Int, - ) { - companion object { - fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape( - active = state != null && state.duration > 0.0 && !state.ended, - paused = state?.paused != false, - width = state?.videoWidth?.toInt() ?: 0, - height = state?.videoHeight?.toInt() ?: 0, - ) - } - } - - private fun PictureInPictureShape.params(): PictureInPictureParams = - PictureInPictureParams.Builder() - .setAspectRatio(aspectRatio(width, height)) - .setAutoEnterEnabled(active) - .setSeamlessResizeEnabled(false) - // The player owns the whole window, so the picture's rectangle is - // the window's. Without it the transition into PiP cross-fades from - // a screenshot instead of shrinking the video that is already there. - .setSourceRectHint(windowBounds()) - .setActions( - listOf( - remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11), - if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12) - else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13), - remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14), - ), - ) - .build() - - private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) } - - private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction = - RemoteAction( - Icon.createWithResource(this, icon), - label, - label, - PendingIntent.getBroadcast( - this, - requestCode, - Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ), - ) - - private companion object { - const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL" - const val EXTRA_CONTROL = "control" - const val CONTROL_PLAY = "play" - const val CONTROL_PAUSE = "pause" - const val CONTROL_NEXT = "next" - const val CONTROL_PREVIOUS = "previous" - - /** - * Android refuses a window narrower than 1:2.39 or taller than 2.39:1, - * and refuses the request outright rather than clamping it — so a - * 2.40:1 scope release would take PiP away entirely. - */ - fun aspectRatio(width: Int, height: Int): Rational { - if (width <= 0 || height <= 0) return Rational(16, 9) - val ratio = width.toDouble() / height - return when { - ratio > MAX_RATIO -> Rational(239, 100) - ratio < 1 / MAX_RATIO -> Rational(100, 239) - else -> Rational(width, height) - } - } - - const val MAX_RATIO = 2.39 - } } diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt index bd2a0e6..a558117 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt @@ -85,9 +85,9 @@ data class MpvPlaybackState( /** * What is on screen, for the surfaces that are not the player. * - * The media session, its notification and Picture-in-Picture all have to name - * the episode, and none of them can reach the composition that knows it. The - * player publishes this once per episode and they read it. + * The media session and its notification have to name the episode, and + * neither can reach the composition that knows it. The player publishes this + * once per episode and they read it. */ data class NowPlaying( val media: String, @@ -134,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv /** * Where a transport outside the player sends previous/next. * - * The player owns which episode is open, so the notification, the lock - * screen and Picture-in-Picture cannot walk the playlist themselves. Both + * The player owns which episode is open, so the notification and the lock + * screen cannot walk the playlist themselves. Both * run on the main thread, which is where the player's state lives. */ var onSkipPrevious: (() -> Unit)? = null diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt index 2083dc3..eb4a4a4 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlaybackService.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */ +/** Owns libmpv so audio survives Activity recreation and rotation. */ class PlaybackService : Service() { inner class PlaybackBinder : Binder() { val host: NativeMpvHost? get() = this@PlaybackService.host diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index bffcdf0..960f159 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -131,18 +131,14 @@ private val VOLUME_SLIDER_MIN_WIDTH = 600.dp /** * The player, as its own screen on the activity's own window. * - * Not a dialog: a dialog window has its own insets, cannot be told to draw into - * the display cutout, and hands Picture-in-Picture the wrong window. Owning the - * activity window is what makes "fullscreen" mean the whole panel, notch - * included. + * Not a dialog: a dialog window has its own insets and cannot be told to draw + * into the display cutout. Owning the activity window is what makes + * "fullscreen" mean the whole panel, notch included. * * [episodes] is the title's episodes in display order and [index] the one that is * open, which is what previous/next and autoplay walk. The index is hoisted so * that it survives process recreation with the rest of the navigation state — * this screen is destroyed and rebuilt by a dark-mode toggle like any other. - * - * In [inPictureInPicture] the window is a thumbnail with the system's own - * controls over it, so this screen draws the picture and nothing else. */ @Composable fun PlayerScreen( @@ -150,7 +146,6 @@ fun PlayerScreen( episodes: List, index: Int, host: LibmpvHost?, - inPictureInPicture: Boolean, minimized: Boolean, onIndexChange: (Int) -> Unit, onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit, @@ -294,9 +289,8 @@ fun PlayerScreen( onIndexChange(target) } - // What the media notification, the lock screen and Picture-in-Picture show. - // None of them can reach this composition, and all three have to name the - // episode rather than the app. + // What the media notification and the lock screen show. Neither can reach + // this composition, and both have to name the episode rather than the app. LaunchedEffect(key, index, episodes.size, title.key) { nativeHost.publish( NowPlaying( @@ -359,12 +353,6 @@ fun PlayerScreen( showControls = false } } - // Everything this screen draws over the picture is sized for a phone panel - // and unreadable in a thumbnail, and Android supplies its own transport - // there. Leaving PiP must find the controls up rather than mid-timeout. - val overlays = !inPictureInPicture - LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true } - if (minimized) return Box( @@ -374,7 +362,6 @@ fun PlayerScreen( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - enabled = overlays, onClick = { showControls = !showControls }, ), ) { @@ -393,7 +380,7 @@ fun PlayerScreen( ) AnimatedVisibility( - visible = overlays && showControls, + visible = showControls, modifier = Modifier.align(Alignment.TopStart), enter = fadeIn(), exit = fadeOut(), @@ -450,7 +437,7 @@ fun PlayerScreen( } AnimatedVisibility( - visible = overlays && showControls, + visible = showControls, modifier = Modifier.align(Alignment.BottomCenter), enter = fadeIn(), exit = fadeOut(), @@ -476,7 +463,7 @@ fun PlayerScreen( // Drawn whether or not the rest of the controls are up: an opening lasts // ninety seconds, and a button you have to wake with a tap is one nobody // reaches in time. - offer?.takeIf { overlays && !autoSkip }?.let { skip -> + offer?.takeIf { !autoSkip }?.let { skip -> TonalButton( onClick = { nativeHost.seek(skip.target) }, modifier = Modifier @@ -488,7 +475,7 @@ fun PlayerScreen( UpNextCard( next = next, - visible = overlays && autoplay && !state.paused && + visible = autoplay && !state.paused && chapters.creditsStart?.let { state.position >= it } == true, onPlayNow = { advance() }, modifier = Modifier diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt index 1eef576..8796565 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt @@ -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" diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index 5094c94..e30529b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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? = 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 = 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, announce: Boolean) { + if (ids.isEmpty()) return + val before = mutableState.value.titles.map { it.key }.toSet() + val failed = mutableListOf() + 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) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 433d3df..2c90e9b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt index 95cc2fc..b29cddd 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -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(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") } }, ) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index 4db8fcf..aa152e9 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -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, - 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(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") } }, ) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt index efec479..78610fc 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt @@ -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 diff --git a/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt index b18a6fb..e8974e4 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt @@ -9,7 +9,7 @@ class AccountUiTest { @Test fun `the sync line says how long ago and what came in`() { val minute = 60_000L - assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0)) + assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0)) assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000)) assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute)) assertEquals( @@ -21,7 +21,7 @@ class AccountUiTest { @Test fun `a failed sync is said in place of when it last worked`() { assertEquals( - "Watch history did not sync: offline", + "Did not sync: offline", syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0), ) } diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index a720c18..896a63e 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -137,22 +137,22 @@ class ScreenshotTest { @Test fun `the shares page`() = capture("shares-phone") { - SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues()) + SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues()) } @Test fun `the settings page`() = capture("settings-phone") { - SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0") + SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0") } @Test fun `the playback settings`() = capture("settings-playback") { - SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) + SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) } @Test fun `the storage settings`() = capture("settings-storage") { - SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) + SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) } @Test diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index a24ba14..442d4b8 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -187,6 +187,16 @@ pub struct SyncRecord { pub uploaded: bool, /// Other installations whose history was read. pub devices: u32, + /// Shares another device added or changed, configured here and not yet + /// crawled. Crawl each. + pub shares_added: Vec, + /// Shares another device removed, gone here with their offline files. + /// Drop any download state kept for them. + pub shares_removed: Vec, + /// Whether settings changed: repaint, and re-read the metadata settings. + pub settings_changed: bool, + /// Hand-picked matches and per-show track choices taken. + pub titles: u32, } #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] @@ -704,6 +714,22 @@ impl Drop for AbortOnDrop { } } +/// Why a link could not be added, in words a viewer can act on. +/// +/// A link that is already in the library and one that is not a link at all +/// say so themselves; anything else failed at the handshake, where a wrong +/// custom password and an expired link look the same from here. +fn add_share_failure(error: &pstr_core::Error) -> String { + match error { + pstr_core::Error::Config(reason) => reason.clone(), + pstr_core::Error::Drive(_) => format!( + "Could not open that link: {error}. Check that it is complete, \ + still shared, and that the password is right." + ), + other => other.to_string(), + } +} + /// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the /// caller stops waiting. Awaits to exactly what `JoinHandle` does. fn spawned( @@ -1143,12 +1169,31 @@ impl AndroidEngine { else { return Ok(None); }; - match sync.sync(&account, &self.catalog).await { - Ok(report) => Ok(Some(SyncRecord { - applied: u32::try_from(report.applied).unwrap_or(u32::MAX), - uploaded: report.uploaded, - devices: u32::try_from(report.devices).unwrap_or(u32::MAX), - })), + match sync.sync(&account, &self.catalog, &self.store).await { + Ok(report) => { + let profile = report.profile; + for id in profile.shares_added.iter().chain(&profile.shares_changed) { + self.invalidate_connection(id); + } + for id in &profile.shares_removed { + if let Err(error) = self.forget_synced_share(id) { + log::warn!("files of {id}, a share removed elsewhere: {error}"); + } + } + Ok(Some(SyncRecord { + applied: u32::try_from(report.applied).unwrap_or(u32::MAX), + uploaded: report.uploaded, + devices: u32::try_from(report.devices).unwrap_or(u32::MAX), + shares_added: profile + .shares_added + .into_iter() + .chain(profile.shares_changed) + .collect(), + shares_removed: profile.shares_removed, + settings_changed: profile.settings, + titles: u32::try_from(profile.titles).unwrap_or(u32::MAX), + })) + } Err(error) if error.is_signed_out() => { let _ = self.accounts.sign_out(None).await; self.signed_out(); @@ -1223,6 +1268,26 @@ impl AndroidEngine { Ok(share_record(share)) } + /// Configured shares with nothing in the catalog: brought in by sync, + /// possibly while the app was in the background, or added here and never + /// crawled to the end. Crawl each. An empty share is crawled every time, + /// which costs one listing. + pub fn uncrawled_shares(&self) -> Result, BridgeError> { + let shares = self.store.list().map_err(BridgeError::from_display)?; + let catalog = self.catalog.lock(); + let mut uncrawled = Vec::new(); + for share in shares { + if catalog + .files(&share.id) + .map_err(BridgeError::from_display)? + .is_empty() + { + uncrawled.push(share.id); + } + } + Ok(uncrawled) + } + pub fn shares(&self) -> Result, BridgeError> { self.store .list() @@ -1230,18 +1295,34 @@ impl AndroidEngine { .map(|shares| shares.into_iter().map(share_record).collect()) } - pub fn add_share( - &self, + /// Add a public link, once it has opened with the password given. A link + /// that will not open is refused and leaves nothing behind. Crawl the + /// returned share next. + pub async fn add_share( + self: Arc, name: String, url: String, custom_password: Option, ) -> Result { - let share = self - .store - .add(&name, &url, custom_password.as_deref()) - .map_err(BridgeError::from_display)?; - self.invalidate_connection(&share.id); - Ok(share_record(share)) + let runtime = Arc::clone(&self.runtime); + spawned(&runtime, async move { + let (share, client) = self + .store + .add_verified(&name, &url, custom_password.as_deref()) + .await + .map_err(|error| BridgeError::Failure { + reason: add_share_failure(&error), + })?; + self.invalidate_connection(&share.id); + // The handshake that proved the link is the one the crawl uses. + self.reusable_clients + .lock() + .get_or_insert_with(Default::default) + .insert(share.id.clone(), client); + Ok(share_record(share)) + }) + .await + .map_err(BridgeError::from_display)? } /// Re-supply the link behind a share whose stored secret is unreadable. @@ -1291,28 +1372,7 @@ impl AndroidEngine { // when secret cleanup fails, old authenticated clients and catalog // rows must not remain usable. self.invalidate_connection(&share_id); - - for ((stored_share_id, link_id), file) in offline { - if stored_share_id == share_id { - let path = self - .dirs - .offline_file(&share_id, &link_id, &file.revision_id); - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(BridgeError::from_display(error)), - } - } - } - for link_id in links { - let (partial, marker) = self.partial_paths(&share_id, &link_id); - let _ = std::fs::remove_file(partial); - let _ = std::fs::remove_file(marker); - } - self.catalog - .lock() - .remove_share(&share_id) - .map_err(BridgeError::from_display)?; + self.forget_share_files(&share_id, offline, links)?; store_result.map_err(BridgeError::from_display) } @@ -1901,6 +1961,56 @@ impl AndroidEngine { } impl AndroidEngine { + /// Delete a removed share's offline and partial files, then its catalog + /// rows. Watch state stays; see `Catalog::remove_share`. + fn forget_share_files( + &self, + share_id: &str, + offline: HashMap<(String, String), OfflineFile>, + links: Vec, + ) -> Result<(), BridgeError> { + for ((stored_share_id, link_id), file) in offline { + if stored_share_id == share_id { + let path = self + .dirs + .offline_file(share_id, &link_id, &file.revision_id); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(BridgeError::from_display(error)), + } + } + } + for link_id in links { + let (partial, marker) = self.partial_paths(share_id, &link_id); + let _ = std::fs::remove_file(partial); + let _ = std::fs::remove_file(marker); + } + self.catalog + .lock() + .remove_share(share_id) + .map_err(BridgeError::from_display) + } + + /// [`Self::forget_share_files`] for a share another device removed, which + /// sync has already taken out of the configuration. + fn forget_synced_share(&self, share_id: &str) -> Result<(), BridgeError> { + let _publication = self.share_publication.lock(); + let catalog = self.catalog.lock(); + let offline = catalog + .all_offline_files() + .map_err(BridgeError::from_display)?; + let links: Vec = catalog + .files(share_id) + .map_err(BridgeError::from_display)? + .into_iter() + .map(|node| node.link_id) + .collect(); + drop(catalog); + self.invalidate_connection(share_id); + self.forget_share_files(share_id, offline, links) + } + /// One enrichment pass over the whole library. /// /// What to do per title is decided by `pstr_meta::service::plan`, the same @@ -3765,13 +3875,13 @@ mod tests { ) .expect("engine"); + // Stored directly: `add_share` opens the link first, and a test has + // no network to open it over. let share = engine - .add_share( - "test".to_owned(), - "https://drive.proton.me/urls/ABC123#s3cr3t".to_owned(), - None, - ) + .store + .add("test", "https://drive.proton.me/urls/ABC123#s3cr3t", None) .expect("add share"); + engine.invalidate_connection(&share.id); assert_eq!(engine.share_generation.load(Ordering::Acquire), 1); assert!( engine diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 386edf2..a8c621b 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -793,6 +793,10 @@ impl App { self.view.stale = true; } Event::EpisodeMetadata(episodes) => self.episodes = episodes, + Event::Appearance(appearance) => { + theme::apply(ctx, appearance); + self.view.stale = true; + } Event::MetadataConfig(config) => { self.settings = config; self.posters.clear(); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 309df27..ad7fd6f 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -34,6 +34,7 @@ use pstr_core::config::AppDirs; use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::prefs::PlaybackPrefs; +use pstr_core::profile::ProfileChanges; use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; @@ -215,6 +216,8 @@ pub enum Event { EpisodeMetadata(HashMap), /// The enrichment settings changed, on disk. MetadataConfig(MetadataConfig), + /// Another device's appearance arrived through sync. + Appearance(Appearance), /// A matching run finished, with how it went. Matched { matched: usize, @@ -279,7 +282,7 @@ pub enum Event { SignInFailed(String), /// Watch history synced with the account's Drive. Synced(SyncReport), - /// Watch history did not sync, with why. Not a toast: it is retried on a + /// Sync did not run, with why. Not a toast: it is retried on a /// timer, and an offline evening would be a stack of them. SyncFailed(String), /// The account's top-level places, for the Drive browser. @@ -640,12 +643,17 @@ impl Engine { } /// Add a share, then open and crawl it — which is what "add" means to - /// someone who just pasted a link. + /// someone who just pasted a link. A link that will not open is refused + /// before anything is stored, so trying again is not "already configured". pub fn add_share(&self, name: String, url: String, password: Option) { let engine = self.clone(); self.runtime.spawn(async move { - let share = match engine.store.add(&name, &url, password.as_deref()) { - Ok(share) => share, + let share = match engine + .store + .add_verified(&name, &url, password.as_deref()) + .await + { + Ok((share, _)) => share, Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), }; engine.emit(Event::ShareAdded(share.name.clone())); @@ -836,9 +844,10 @@ impl Engine { }; let engine = self.clone(); self.runtime.spawn(async move { - match sync.sync(&account, &engine.catalog).await { + match sync.sync(&account, &engine.catalog, &engine.store).await { Ok(report) => { - if report.applied > 0 { + engine.take_synced_profile(&report.profile).await; + if report.applied > 0 || report.profile.titles > 0 { engine.load_library(); } engine.emit(Event::Synced(report)); @@ -875,8 +884,9 @@ impl Engine { return; }; let catalog = Arc::clone(&self.catalog); + let store = Arc::clone(&self.store); self.runtime.block_on(async move { - match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await { + match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await { Ok(Ok(_)) => {} Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"), Err(_) => tracing::warn!("sync watch history on exit: timed out"), @@ -884,6 +894,58 @@ impl Engine { }); } + /// Act on what sync brought from other devices: reread the settings it + /// rewrote, crawl the shares it added, drop the ones it removed. + async fn take_synced_profile(&self, profile: &ProfileChanges) { + if profile.settings { + match pstr_core::prefs::load(&self.dirs) { + // The volume is this device's and was not synced; the file + // still holds it. + Ok(prefs) => *self.prefs.lock() = prefs, + Err(error) => tracing::warn!("reread the playback preferences: {error}"), + } + match pstr_core::appearance::load(&self.dirs) { + Ok(appearance) => { + *self.appearance.lock() = appearance; + self.emit(Event::Appearance(appearance)); + } + Err(error) => tracing::warn!("reread the appearance: {error}"), + } + match pstr_meta::settings::load(&self.dirs) { + Ok(config) => { + let engine = self.clone(); + let _ = self + .runtime + .spawn_blocking(move || { + engine.install_enrichment(Enrichment::build(config.clone()), false); + engine.emit(Event::MetadataConfig(config)); + }) + .await; + } + Err(error) => tracing::warn!("reread the metadata settings: {error}"), + } + } + if profile.titles > 0 { + match self.catalog.lock().all_title_track_prefs() { + Ok(prefs) => *self.track_prefs.lock() = prefs, + Err(error) => tracing::warn!("reread show track preferences: {error}"), + } + self.load_metadata(); + } + for id in &profile.shares_removed { + self.forget_share(id.clone(), false).await; + } + if !profile.shares() { + return; + } + self.connection.lock().take(); + self.load_shares(); + self.load_library(); + for id in profile.shares_added.iter().chain(&profile.shares_changed) { + self.connect_and_crawl(Some(id.clone())).await; + } + } + fn sync_periodically(&self) { let engine = self.clone(); self.runtime.spawn(async move { @@ -929,6 +991,19 @@ impl Engine { pub fn remove_share(&self, id: String) { let engine = self.clone(); self.runtime.spawn(async move { + engine.forget_share(id, true).await; + engine.connection.lock().take(); + engine.load_library(); + engine.emit(Event::Status("removed".into())); + }); + } + + /// Stop a share's downloads and delete its files and catalog rows, and, + /// when it is still `configured` — not already removed by sync — its + /// entry and secrets. + async fn forget_share(&self, id: String, configured: bool) { + let engine = self; + { let keys: Vec<_> = engine .downloads .lock() @@ -971,7 +1046,7 @@ impl Engine { if let Err(error) = engine.catalog.lock().remove_share(&id) { return engine.fail("drop that share's catalog rows", error); } - if let Err(error) = engine.store.remove(&id) { + if configured && let Err(error) = engine.store.remove(&id) { return engine.fail("remove that share", error); } { @@ -983,9 +1058,7 @@ impl Engine { engine.load_shares(); // Its clients are stale now; the next action reopens what is left. engine.connection.lock().take(); - engine.load_library(); - engine.emit(Event::Status("removed".into())); - }); + } } // ------------------------------------------------------------ connection diff --git a/crates/pstr-app/src/ui/account.rs b/crates/pstr-app/src/ui/account.rs index f45da17..fd56fc9 100644 --- a/crates/pstr-app/src/ui/account.rs +++ b/crates/pstr-app/src/ui/account.rs @@ -133,9 +133,10 @@ impl DriveBrowser { pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec) { ui::section(ui, "Proton account"); ui.label(ui::muted( - "Optional. Signed in, your watch history is kept in your own Drive and every device \ - resumes where another left off, and folders of your Drive — your files, other \ - devices, what was shared with you — can be added to 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 — your files, other devices, what was shared with you — \ + can be added to the library.", )); ui.add_space(theme::space::L); @@ -281,9 +282,11 @@ fn signed_in( .strong(), ); let status = match (&panel.sync_error, &panel.synced) { - (Some(error), _) => format!("Watch history did not sync: {error}"), + (Some(error), _) => format!("Did not sync: {error}"), (None, Some((at, report))) => sync_line(*at, report, unix_now()), - (None, None) => "Watch history syncs with your Drive".to_owned(), + (None, None) => { + "Library, settings and watch history sync through your Drive".to_owned() + } }; ui.label(ui::muted(status)); }); diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index de76b76..da60588 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc) -> Result<()> { } else { None }; - let share = store - .add(&name, &url, password.as_deref()) + let (share, _) = store + .add_verified(&name, &url, password.as_deref()) + .await .context("add share")?; println!("added {} ({})", share.name, share.id); } @@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc) -> Result<()> { let sync = WatchSync::open(&dirs).context("open sync state")?; let catalog = parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?); - let report = sync.sync(&account, &catalog).await.context("sync")?; + let report = sync + .sync(&account, &catalog, &store) + .await + .context("sync")?; + let profile = &report.profile; + for id in &profile.shares_removed { + catalog + .lock() + .remove_share(id) + .context("drop a removed share")?; + } println!( - "read {} other device(s), took {} newer position(s), {}", + "read {} other device(s), took {} newer position(s), \ + {} share(s) added, {} removed, settings {}, {}", report.devices, report.applied, + profile.shares_added.len() + profile.shares_changed.len(), + profile.shares_removed.len(), + if profile.settings { + "updated" + } else { + "unchanged" + }, if report.uploaded { "uploaded this device's history" } else { diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 7f8996f..5b9245f 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod library; pub mod metadata; pub mod naming; pub mod prefs; +pub mod profile; pub mod shares; pub mod shelves; pub mod sync; diff --git a/crates/pstr-core/src/profile.rs b/crates/pstr-core/src/profile.rs new file mode 100644 index 0000000..a2a9bb4 --- /dev/null +++ b/crates/pstr-core/src/profile.rs @@ -0,0 +1,808 @@ +//! The rest of what makes the app look the same on every device: the shares, +//! the settings, the hand-picked metadata matches and the per-show track +//! choices. Synced next to watch history by [`crate::sync`], in the same file. +//! +//! ## Registers +//! +//! Each synced thing is one *register*: a key (`share/`, +//! `settings/playback`, `tracks/`, …), its latest value, and when it +//! last changed. Merging is the same rule as watch history — the newer +//! `updated_at` wins, per key — so it is idempotent and order-free across any +//! number of devices. A removed share is a register with no value, so a +//! removal travels like any other change. +//! +//! ## Noticing changes +//! +//! Nothing that writes a setting or a share knows about sync. Instead +//! `sync-state.json` remembers every register as last synced, and each sync +//! compares it with what is on this device: whatever differs changed since, +//! and is stamped with the time of the sync. That costs a change made between +//! syncs its exact time, which matters only when two devices change the same +//! setting within one sync interval of each other. +//! +//! The first sync of an installation stamps what it finds at zero rather than +//! now. A fresh install holds only defaults, and those must not outrank the +//! library every other device already agreed on; what it has that nobody else +//! does is still added, since nothing older competes with it. +//! +//! ## What does not travel +//! +//! The volume and mute, which belong to the speakers in front of the viewer, +//! and every setting the front ends keep themselves: the cache budget, Wi-Fi +//! only, background audio, hardware decoding, window geometry. +//! +//! ## Secrets +//! +//! A share's register carries its link, fragment included, and its custom +//! password, and the TMDB API key travels too: without them another device +//! could list the share but never open it. The file holding them is in the +//! viewer's own Drive, end-to-end encrypted with their account's keys, which is +//! the same trust the credential store gives them locally; they are never +//! written anywhere else in plain text. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::catalog::{Catalog, TitleTrackPrefs}; +use crate::config::{AppDirs, read_json, write_json}; +use crate::error::Result; +use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata}; +use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore}; + +const SHARE: &str = "share/"; +const TRACKS: &str = "tracks/"; +const MATCH: &str = "match/"; + +/// The settings files that travel, by register, and the fields in each that +/// stay on the device that wrote them. +const SETTINGS: [(&str, &str, &[&str]); 3] = [ + ("settings/playback", "playback.json", &["volume", "muted"]), + ("settings/appearance", "appearance.json", &[]), + ("settings/metadata", "metadata.json", &[]), +]; + +const METADATA_SETTINGS: &str = "settings/metadata"; + +/// One synced value and when it last changed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Register { + /// `None` is a removal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option<Value>, + pub updated_at: i64, +} + +pub(crate) type Registers = BTreeMap<String, Register>; + +/// What this installation last synced, kept beside its sync identity. +#[derive(Debug, Default, Serialize, Deserialize)] +struct State { + registers: Registers, +} + +/// A share as it travels: everything another device needs to open it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SyncedShare { + name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_password: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + folder: Option<AccountFolder>, +} + +/// A match the viewer picked by hand. Automatic ones are not synced: every +/// device with enrichment on finds them again, and they expire anyway. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct SyncedMatch { + provider: ProviderId, + metadata: Option<TitleMetadata>, + fetched_at: i64, +} + +/// What applying other devices' registers changed here, for the front end to +/// act on: reopen and crawl shares, repaint, reload the library. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProfileChanges { + /// Shares this device did not have, now configured. Not yet crawled. + pub shares_added: Vec<String>, + /// Shares whose name or secrets changed. + pub shares_changed: Vec<String>, + /// Shares removed elsewhere, now gone from the configuration here. Their + /// catalog rows and offline files are the front end's to drop, the same + /// way it does for a share removed on this device. + pub shares_removed: Vec<String>, + /// Whether any settings file or the TMDB key changed. + pub settings: bool, + /// Hand-picked matches and per-show track choices taken. + pub titles: usize, +} + +impl ProfileChanges { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + pub fn shares(&self) -> bool { + !(self.shares_added.is_empty() + && self.shares_changed.is_empty() + && self.shares_removed.is_empty()) + } +} + +/// The local side of profile sync: where the state file lives, and the stores +/// registers are read from and applied to. +pub(crate) struct Profile<'a> { + pub store: &'a ShareStore, + pub catalog: &'a Mutex<Catalog>, +} + +/// What is on this device, register by register. +#[derive(Debug, Default)] +struct Observed { + values: BTreeMap<String, Value>, + /// Keys that exist but could not be read — a share whose secret will not + /// decrypt. Neither a change nor a removal. + unknown: BTreeSet<String>, + /// Whether the share list itself could be read. Without it, a share + /// missing from `values` says nothing about whether it was removed. + shares_listed: bool, +} + +fn state_file(dirs: &AppDirs) -> PathBuf { + dirs.config.join("sync-state.json") +} + +fn tmdb_secret() -> String { + format!("metadata-{}", ProviderId::Tmdb.as_str()) +} + +const TMDB_KEY: &str = "secret/metadata-tmdb"; + +impl Profile<'_> { + fn dirs(&self) -> &AppDirs { + &self.store.dirs + } + + /// Read the state, notice what changed here since, take what other + /// devices have newer, and write the state back. Returns the registers to + /// upload and what changed on this device. + pub fn sync<'r>( + &self, + remote: impl IntoIterator<Item = &'r Registers>, + now: i64, + ) -> Result<(Registers, ProfileChanges)> { + let path = state_file(self.dirs()); + let stored = read_json::<State>(&path)?; + let first = stored.is_none(); + let mut state = stored.unwrap_or_default(); + + let observed = self.observe(); + stamp(&mut state.registers, observed, if first { 0 } else { now }); + let winners = merge(&state.registers, remote); + let changes = self.apply(&mut state.registers, winners); + write_json(&path, &state)?; + Ok((state.registers, changes)) + } + + fn observe(&self) -> Observed { + let mut observed = Observed::default(); + for (key, file, local) in SETTINGS { + match read_json::<Value>(&self.dirs().config.join(file)) { + Ok(Some(value)) => { + observed + .values + .insert(key.to_owned(), without(value, local)); + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, file, "a settings file was not synced"); + observed.unknown.insert(key.to_owned()); + } + } + } + match self.store.secrets.get(&tmdb_secret()) { + Ok(Some(key)) => { + observed + .values + .insert(TMDB_KEY.to_owned(), Value::String(key)); + } + Ok(None) => {} + Err(_) => { + observed.unknown.insert(TMDB_KEY.to_owned()); + } + } + + if let Ok(shares) = self.store.list() { + observed.shares_listed = true; + for share in shares { + let key = format!("{SHARE}{}", share.id); + let secrets = if share.folder.is_some() { + None + } else { + match self.store.secrets_of(&share.id) { + Ok(secrets) => Some(secrets), + Err(_) => { + observed.unknown.insert(key); + continue; + } + } + }; + let synced = SyncedShare { + name: share.name, + token: share.token, + url: secrets.as_ref().map(|secrets| secrets.url.clone()), + custom_password: secrets.and_then(|secrets| secrets.custom_password), + folder: share.folder, + }; + if let Ok(value) = serde_json::to_value(synced) { + observed.values.insert(key, value); + } + } + } + + let catalog = self.catalog.lock(); + if let Ok(tracks) = catalog.all_title_track_prefs() { + for (title, prefs) in tracks { + if let Ok(value) = serde_json::to_value(prefs) { + observed.values.insert(format!("{TRACKS}{title}"), value); + } + } + } + if let Ok(records) = catalog.all_metadata() { + for (title, record) in records { + if !record.manual { + continue; + } + let synced = SyncedMatch { + provider: record.provider, + metadata: record.metadata, + fetched_at: record.fetched_at, + }; + if let Ok(value) = serde_json::to_value(synced) { + observed.values.insert(format!("{MATCH}{title}"), value); + } + } + } + observed + } + + /// Apply each winning register here, recording it in `registers` once it + /// took. One that fails is left out, so the next sync tries it again. + fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges { + let mut changes = ProfileChanges::default(); + for (key, register) in winners { + match self.apply_one(&key, register.value.as_ref(), &mut changes) { + Ok(()) => { + registers.insert(key, register); + } + Err(error) => tracing::warn!(%error, key, "a synced change was not applied"), + } + } + changes + } + + fn apply_one( + &self, + key: &str, + value: Option<&Value>, + changes: &mut ProfileChanges, + ) -> Result<()> { + if let Some(id) = key.strip_prefix(SHARE) { + return self.apply_share(id, value, changes); + } + if let Some(title) = key.strip_prefix(TRACKS) { + if let Some(value) = value { + let prefs: TitleTrackPrefs = parse(value)?; + self.catalog.lock().set_title_track_prefs(title, &prefs)?; + changes.titles += 1; + } + return Ok(()); + } + if let Some(title) = key.strip_prefix(MATCH) { + if let Some(value) = value { + let synced: SyncedMatch = parse(value)?; + self.catalog.lock().set_metadata(&MetadataRecord { + title_key: title.to_owned(), + provider: synced.provider, + metadata: synced.metadata, + fetched_at: synced.fetched_at, + manual: true, + })?; + changes.titles += 1; + } + return Ok(()); + } + if key == TMDB_KEY { + match value.and_then(Value::as_str) { + Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?, + None => self.store.secrets.delete(&tmdb_secret())?, + } + changes.settings = true; + return Ok(()); + } + if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) { + let Some(Value::Object(incoming)) = value else { + return Ok(()); + }; + let path = self.dirs().config.join(file); + let before = read_json::<Value>(&path)?; + let mut merged = match &before { + Some(Value::Object(existing)) => existing.clone(), + _ => serde_json::Map::new(), + }; + for (field, field_value) in incoming { + if !local.contains(&field.as_str()) { + merged.insert(field.clone(), field_value.clone()); + } + } + let merged = Value::Object(merged); + if key == METADATA_SETTINGS { + self.metadata_settings_changing(before.as_ref(), &merged)?; + } + write_json(&path, &merged)?; + changes.settings = true; + return Ok(()); + } + // A register a newer build writes. Kept, so it travels on. + Ok(()) + } + + fn apply_share( + &self, + id: &str, + value: Option<&Value>, + changes: &mut ProfileChanges, + ) -> Result<()> { + let existing = self.store.list()?.into_iter().find(|share| share.id == id); + let Some(value) = value else { + if existing.is_some() { + self.store.remove(id)?; + changes.shares_removed.push(id.to_owned()); + } + return Ok(()); + }; + let synced: SyncedShare = parse(value)?; + let expected = match (&synced.folder, &synced.url) { + (Some(folder), _) => folder.share_id(), + (None, Some(_)) => format!("share-{}", synced.token), + (None, None) => return Err(invalid("a synced link has no URL")), + }; + if expected != id { + return Err(invalid("a synced share's id does not match what it opens")); + } + let secrets = synced.url.map(|url| ShareSecrets { + url, + custom_password: synced.custom_password, + }); + let share = Share { + id: id.to_owned(), + name: synced.name, + token: synced.token, + has_custom_password: secrets + .as_ref() + .is_some_and(|secrets| secrets.custom_password.is_some()), + folder: synced.folder, + }; + if existing.as_ref() == Some(&share) + && secrets.as_ref().is_none_or(|secrets| { + self.store + .secrets_of(id) + .is_ok_and(|stored| &stored == secrets) + }) + { + return Ok(()); + } + self.store.put_synced(share, secrets.as_ref())?; + if existing.is_some() { + changes.shares_changed.push(id.to_owned()); + } else { + changes.shares_added.push(id.to_owned()); + } + Ok(()) + } + + /// Turning enrichment off or switching provider drops every stored answer + /// on the device that did it, which the settings page also does here. + fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> { + let config = |value: Option<&Value>| -> MetadataConfig { + value + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default() + }; + let (before, after) = (config(before), config(Some(after))); + if !after.enabled || before.provider != after.provider { + self.catalog.lock().clear_metadata()?; + } + Ok(()) + } +} + +/// Record what changed on this device since the last sync, stamped `now`. +fn stamp(registers: &mut Registers, observed: Observed, now: i64) { + for (key, register) in registers.iter_mut() { + if register.value.is_some() + && key.starts_with(SHARE) + && observed.shares_listed + && !observed.values.contains_key(key) + && !observed.unknown.contains(key) + { + *register = Register { + value: None, + updated_at: now, + }; + } + } + for (key, value) in observed.values { + let unchanged = registers + .get(&key) + .is_some_and(|register| same(&key, register.value.as_ref(), Some(&value))); + if !unchanged { + registers.insert( + key, + Register { + value: Some(value), + updated_at: now, + }, + ); + } + } +} + +/// Whether two values of one register are the same choice. +/// +/// A match is compared by what it points at: its stored details move every +/// time the provider is asked again, and a register re-stamped by every +/// refresh would bounce between devices for ever. +fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool { + if key.starts_with(MATCH) { + let pointer = |value: Option<&Value>| { + value.map(|value| { + ( + value.get("provider").cloned(), + value + .get("metadata") + .and_then(|metadata| metadata.get("remote_id")) + .cloned(), + ) + }) + }; + return pointer(a) == pointer(b); + } + a == b +} + +/// The registers from other devices that are newer than this one's. +fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers { + let mut winners = Registers::new(); + for registers in remote { + for (key, register) in registers { + let current = winners.get(key).or_else(|| local.get(key)); + if current.is_none_or(|current| newer(register, current)) { + winners.insert(key.clone(), register.clone()); + } + } + } + winners +} + +/// Newer wins; a tie between two different values goes the same way on every +/// device, or two of them would each keep their own. +fn newer(candidate: &Register, current: &Register) -> bool { + if candidate.updated_at != current.updated_at { + return candidate.updated_at > current.updated_at; + } + let order = |register: &Register| { + register + .value + .as_ref() + .map(Value::to_string) + .unwrap_or_default() + }; + order(candidate) > order(current) +} + +/// `value` without the fields that stay on this device. +fn without(value: Value, local: &[&str]) -> Value { + match value { + Value::Object(mut fields) => { + for field in local { + fields.remove(*field); + } + Value::Object(fields) + } + other => other, + } +} + +fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> { + serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string())) +} + +fn invalid(reason: &str) -> crate::Error { + crate::Error::Config(format!("synced setting: {reason}")) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use super::*; + use crate::shares::SecretStore; + + #[derive(Default)] + struct MemorySecrets(Mutex<HashMap<String, String>>); + + impl SecretStore for MemorySecrets { + fn set(&self, key: &str, value: &str) -> Result<()> { + self.0.lock().insert(key.to_owned(), value.to_owned()); + Ok(()) + } + fn get(&self, key: &str) -> Result<Option<String>> { + Ok(self.0.lock().get(key).cloned()) + } + fn delete(&self, key: &str) -> Result<()> { + self.0.lock().remove(key); + Ok(()) + } + } + + /// One installation's configuration and catalog. + pub(crate) struct Device { + root: PathBuf, + pub store: ShareStore, + pub catalog: Mutex<Catalog>, + } + + impl Device { + pub(crate) fn new(tag: &str) -> Self { + let mut bytes = [0_u8; 8]; + getrandom::fill(&mut bytes).unwrap(); + let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}")); + let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap(); + Self { + root, + store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())), + catalog: Mutex::new(Catalog::in_memory().unwrap()), + } + } + + pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> { + self.catalog.lock() + } + + pub(crate) fn profile(&self) -> Profile<'_> { + Profile { + store: &self.store, + catalog: &self.catalog, + } + } + + fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) { + self.profile().sync(remote.iter().copied(), now).unwrap() + } + + fn share_names(&self) -> Vec<String> { + self.store + .list() + .unwrap() + .into_iter() + .map(|share| share.name) + .collect() + } + } + + impl Drop for Device { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart"; + + #[test] + fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.sync(&[], 10); + laptop.store.add("Anime", LINK, Some("hunter2")).unwrap(); + let (sent, _) = laptop.sync(&[], 20); + + let (_, changes) = phone.sync(&[&sent], 30); + + assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]); + assert_eq!(phone.share_names(), ["Anime"]); + let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap(); + assert_eq!(secrets.url, LINK); + assert_eq!(secrets.custom_password.as_deref(), Some("hunter2")); + } + + #[test] + fn a_share_removed_on_one_device_is_removed_on_the_others() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.store.add("Anime", LINK, None).unwrap(); + let (sent, _) = laptop.sync(&[], 10); + let (phone_sent, _) = phone.sync(&[&sent], 20); + + laptop.store.remove("share-ABCDEFGHIJ").unwrap(); + let (sent, _) = laptop.sync(&[&phone_sent], 30); + let (_, changes) = phone.sync(&[&sent], 40); + + assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]); + assert!(phone.share_names().is_empty()); + } + + #[test] + fn a_fresh_installation_takes_the_library_rather_than_its_defaults() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.sync(&[], 10); + let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap(); + prefs.autoplay_next = false; + prefs.speed = 1.5; + crate::prefs::save(&laptop.store.dirs, &prefs).unwrap(); + let (sent, _) = laptop.sync(&[], 20); + + // The new phone has written its defaults before it ever synced. + crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap(); + let (_, changes) = phone.sync(&[&sent], 30); + + assert!(changes.settings); + let taken = crate::prefs::load(&phone.store.dirs).unwrap(); + assert!(!taken.autoplay_next); + assert_eq!(taken.speed, 1.5); + } + + #[test] + fn volume_and_mute_stay_on_the_device_that_set_them() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.sync(&[], 10); + let mut prefs = crate::prefs::PlaybackPrefs { + volume: 20.0, + muted: true, + ..Default::default() + }; + crate::prefs::save(&laptop.store.dirs, &prefs).unwrap(); + let (sent, _) = laptop.sync(&[], 20); + prefs.volume = 80.0; + prefs.muted = false; + prefs.subtitles = false; + crate::prefs::save(&phone.store.dirs, &prefs).unwrap(); + phone.sync(&[], 5); + + phone.sync(&[&sent], 30); + + let taken = crate::prefs::load(&phone.store.dirs).unwrap(); + assert_eq!(taken.volume, 80.0); + assert!(!taken.muted); + assert!(taken.subtitles, "the laptop's later choice wins"); + } + + #[test] + fn the_newer_change_wins_whichever_device_syncs_first() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.sync(&[], 1); + phone.sync(&[], 1); + let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap(); + appearance.gradients = false; + crate::appearance::save(&laptop.store.dirs, &appearance).unwrap(); + let (from_laptop, _) = laptop.sync(&[], 10); + appearance.gradients = true; + appearance.accent = crate::appearance::Accent::Peach; + crate::appearance::save(&phone.store.dirs, &appearance).unwrap(); + let (from_phone, _) = phone.sync(&[&from_laptop], 20); + laptop.sync(&[&from_phone], 30); + + for device in [&laptop, &phone] { + let seen = crate::appearance::load(&device.store.dirs).unwrap(); + assert_eq!(seen.accent, crate::appearance::Accent::Peach); + assert!(seen.gradients); + } + } + + #[test] + fn an_unchanged_device_sends_the_same_registers_again() { + let laptop = Device::new("laptop"); + laptop.store.add("Anime", LINK, None).unwrap(); + let (first, _) = laptop.sync(&[], 10); + let (second, changes) = laptop.sync(&[&first], 20); + assert_eq!(first, second); + assert!(changes.is_empty()); + } + + #[test] + fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() { + let laptop = Device::new("laptop"); + laptop.store.add("Anime", LINK, None).unwrap(); + laptop.sync(&[], 10); + laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap(); + let (sent, _) = laptop.sync(&[], 20); + assert!(sent["share/share-ABCDEFGHIJ"].value.is_some()); + } + + #[test] + fn a_hand_picked_match_and_a_shows_track_choice_travel() { + let laptop = Device::new("laptop"); + let phone = Device::new("phone"); + laptop.sync(&[], 1); + let metadata = TitleMetadata { + provider: ProviderId::AniList, + remote_id: "21".to_owned(), + name: "One Piece".to_owned(), + original_name: None, + overview: None, + year: Some(1999), + kind: crate::library::TitleKind::Series, + poster_url: None, + backdrop_url: None, + rating: None, + genres: Vec::new(), + episodes: None, + url: None, + details: None, + }; + laptop + .catalog + .lock() + .set_metadata(&MetadataRecord { + title_key: "one piece".to_owned(), + provider: ProviderId::AniList, + metadata: Some(metadata), + fetched_at: 5, + manual: true, + }) + .unwrap(); + let tracks = TitleTrackPrefs { + audio_language: Some("jpn".to_owned()), + ..Default::default() + }; + laptop + .catalog + .lock() + .set_title_track_prefs("one piece", &tracks) + .unwrap(); + let (sent, _) = laptop.sync(&[], 10); + + let (_, changes) = phone.sync(&[&sent], 20); + + assert_eq!(changes.titles, 2); + let catalog = phone.catalog.lock(); + let record = catalog.metadata("one piece").unwrap().unwrap(); + assert!(record.manual); + assert_eq!(record.metadata.unwrap().remote_id, "21"); + assert_eq!( + catalog.title_track_prefs("one piece").unwrap(), + Some(tracks) + ); + } + + #[test] + fn a_synced_share_that_names_another_id_is_refused() { + let phone = Device::new("phone"); + let mut forged = Registers::new(); + forged.insert( + "share/share-OTHER".to_owned(), + Register { + value: Some(serde_json::json!({ + "name": "x", "token": "ABCDEFGHIJ", "url": LINK + })), + updated_at: 10, + }, + ); + let (registers, changes) = phone.sync(&[&forged], 20); + assert!(changes.shares_added.is_empty()); + assert!(!registers.contains_key("share/share-OTHER")); + } +} diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 099fb23..5189990 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -355,19 +355,19 @@ impl AccountFolder { } /// The secrets for one share, as held in the credential store. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ShareSecrets { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ShareSecrets { /// The full share URL, fragment included. - url: String, + pub(crate) url: String, /// The custom password, when the link has one. #[serde(default)] - custom_password: Option<String>, + pub(crate) custom_password: Option<String>, } /// The configured shares, and their secrets. pub struct ShareStore { - dirs: AppDirs, - secrets: Arc<dyn SecretStore>, + pub(crate) dirs: AppDirs, + pub(crate) secrets: Arc<dyn SecretStore>, } impl ShareStore { @@ -396,9 +396,9 @@ impl ShareStore { let mut shares = self.list()?; if shares.iter().any(|share| share.id == id) { - return Err(Error::Config(format!( - "a share for token {token} is already configured" - ))); + return Err(Error::Config( + "that link is already in the library".to_owned(), + )); } let secrets = ShareSecrets { @@ -421,6 +421,53 @@ impl ShareStore { Ok(share) } + /// [`Self::add`], but only once the link has been opened with the + /// password given. + /// + /// A link that is mistyped, expired or behind the wrong password used to + /// be stored anyway, so the first try failed on the crawl and every retry + /// failed on "already configured" — the only way out was to remove a share + /// that had never worked. Opening first means a refused link leaves + /// nothing behind, and the client it opened is handed back for the crawl. + pub async fn add_verified( + &self, + name: &str, + url: &str, + custom_password: Option<&str>, + ) -> Result<(Share, ShareClient)> { + let token = token_from_url(url)?; + let id = format!("share-{token}"); + if self.list()?.iter().any(|share| share.id == id) { + return Err(Error::Config( + "that link is already in the library".to_owned(), + )); + } + let custom_password = custom_password.filter(|password| !password.is_empty()); + let client = open_link(url, custom_password).await?; + let share = self.add(name, url, custom_password)?; + Ok((share, client)) + } + + /// Record a share another installation has, with the secrets it opens + /// with, replacing what this one holds under the same id. See + /// [`crate::profile`]. + pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> { + if let Some(secrets) = secrets { + self.store_secrets(&share.id, secrets)?; + } + let mut shares = self.list()?; + match shares.iter_mut().find(|existing| existing.id == share.id) { + Some(existing) => *existing = share, + None => shares.push(share), + } + write_json(&self.dirs.shares_file(), &shares) + } + + /// A share's secrets, for syncing them to another installation. + pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> { + self.load_secrets(id) + } + /// Record a folder of the signed-in account as a share. /// /// Nothing goes in the credential store: the folder opens through the @@ -526,17 +573,7 @@ impl ShareStore { }); } let secrets = self.load_secrets(&share.id)?; - let client = ProtonDrivePublicLinkClient::open( - client_configuration(), - &secrets.url, - secrets.custom_password.as_deref(), - ) - .await? - // The SDK default is sized for a background sync daemon. A player that - // seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB - // ceiling, which is unremarkable for a desktop app. - .with_max_inflight_blocks(48); - Ok(ShareClient::PublicLink(Box::new(client))) + open_link(&secrets.url, secrets.custom_password.as_deref()).await } fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { @@ -556,6 +593,17 @@ impl ShareStore { } } +/// Open a public link as a visitor. +async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> { + let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password) + .await? + // The SDK default is sized for a background sync daemon. A player that + // seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB + // ceiling, which is unremarkable for a desktop app. + .with_max_inflight_blocks(48); + Ok(ShareClient::PublicLink(Box::new(client))) +} + /// Several opened shares, presented as one library. pub struct SharedLibrary { clients: BTreeMap<String, ShareClient>, diff --git a/crates/pstr-core/src/sync.rs b/crates/pstr-core/src/sync.rs index 611e5d8..d2c6691 100644 --- a/crates/pstr-core/src/sync.rs +++ b/crates/pstr-core/src/sync.rs @@ -1,5 +1,7 @@ //! Watch history kept in the viewer's own Drive, so every device resumes where -//! another left off. +//! another left off — and, in the same file, the shares, settings and +//! hand-picked matches that make the library look the same on every device +//! (see [`crate::profile`]). //! //! ## Layout //! @@ -17,7 +19,8 @@ //! nothing is overwritten and there is no write conflict to resolve. //! //! Each file carries the installation's whole watch table, and merging is per -//! episode: the newer `updated_at` wins. That is idempotent and order-free, so +//! episode: the newer `updated_at` wins. The profile's registers merge the same +//! way, per key. That is idempotent and order-free, so //! it does not matter how many devices there are, which synced first, or that //! a device writes back entries it learned from another. A device whose clock //! runs ahead wins ties it should not, which is the price of not having a @@ -25,11 +28,11 @@ //! //! The keys travel: `share-{token}` is derived from the link and a folder's //! `drive-…` id from its node, so the same library added on two devices has -//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the -//! link token decrypts nothing without the fragment — and the file is -//! end-to-end encrypted by Drive regardless. +//! the same `(share_id, link_id)` on both. The watch entries carry nothing +//! secret; the profile's share registers do carry each link and its password, +//! which is why they live only here, end-to-end encrypted by Drive. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use parking_lot::Mutex; use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient}; @@ -40,6 +43,8 @@ use crate::account::Account; use crate::catalog::{Catalog, WatchState}; use crate::config::{AppDirs, read_json, write_json}; use crate::error::{Error, Result}; +use crate::profile::{Profile, ProfileChanges, Registers}; +use crate::shares::ShareStore; /// The Drive device watch history is kept on. pub const DEVICE_NAME: &str = "proton-stream"; @@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream"; const FOLDER: &str = "watch-history"; /// Bumped only for a change an older build would misread. A file of a newer -/// version is skipped rather than half-understood. +/// version is skipped rather than half-understood. The profile's registers +/// were added without a bump: a build that predates them ignores the field. const FORMAT_VERSION: u32 = 1; /// One installation's watch history, as stored in Drive. @@ -57,6 +63,15 @@ struct HistoryFile { version: u32, installation: String, entries: Vec<HistoryEntry>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + registers: Registers, +} + +/// What one history file holds. +#[derive(Debug, Clone, Default, PartialEq)] +struct Contents { + entries: Vec<HistoryEntry>, + registers: Registers, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -112,7 +127,7 @@ struct Identity { } /// What one [`WatchSync::sync`] did. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SyncReport { /// Episodes whose position another device had newer. pub applied: usize, @@ -123,6 +138,8 @@ pub struct SyncReport { /// Files of installations gone quiet that were moved to the trash, their /// history now carried in this installation's own file. pub retired: usize, + /// What other devices' shares and settings changed here. + pub profile: ProfileChanges, } /// How long an installation's file may go unwritten before another @@ -243,10 +260,10 @@ impl HistoryDrive for ProtonDriveClient { } /// A history file as last read, by the revision it was read at. `None` -/// entries for one this build could not read. +/// contents for one this build could not read. struct Seen { revision: Option<String>, - entries: Option<Vec<HistoryEntry>>, + contents: Option<Contents>, } /// Syncs the catalog's watch table with the account's Drive. @@ -257,8 +274,9 @@ pub struct WatchSync { seen: Mutex<HashMap<String, Seen>>, /// What this installation's own file holds, once known: what was read /// back at the first sync, then what was last written. Compared against - /// the catalog so an unchanged history is not uploaded as a new revision. - own: Mutex<Option<Vec<HistoryEntry>>>, + /// the catalog and the profile so an unchanged history is not uploaded as + /// a new revision. + own: Mutex<Option<Contents>>, /// The history folders, resolved once per account. Usually one; see /// [`HistoryDrive::history_folders`]. folders: Mutex<Option<Vec<NodeUid>>>, @@ -303,25 +321,34 @@ impl WatchSync { *self.folders.lock() = None; } - /// Pull every other installation's history into `catalog`, write this - /// one's if it changed, then retire the files of installations gone quiet. + /// Pull every other installation's history, shares and settings in, write + /// this one's if it changed, then retire the files of installations gone + /// quiet. /// /// The catalog is locked only around its own reads and writes, never /// across a request, so playback can keep saving its position while this - /// runs. - pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> { + /// runs. Shares that arrive are configured but not opened or crawled; see + /// [`SyncReport::profile`] for what the front end has to do about them. + pub async fn sync( + &self, + account: &Account, + catalog: &Mutex<Catalog>, + store: &ShareStore, + ) -> Result<SyncReport> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |since| since.as_secs() as i64); - self.sync_with(account.drive(), catalog, now).await + let profile = Profile { store, catalog }; + self.sync_with(account.drive(), &profile, now).await } pub(crate) async fn sync_with<D: HistoryDrive>( &self, drive: &D, - catalog: &Mutex<Catalog>, + profile: &Profile<'_>, now: i64, ) -> Result<SyncReport> { + let catalog = profile.catalog; let _running = self.running.lock().await; let folders = self.folders(drive).await?; let own_name = format!("{}.json", self.installation); @@ -339,12 +366,14 @@ impl WatchSync { } let mut remote = Vec::new(); + let mut registers = Vec::new(); // Files whose every entry has been read, and so can be retired once // this installation's file carries them. let mut readable = Vec::new(); for file in &others { - if let Some(entries) = self.read(drive, file).await { - remote.extend(entries); + if let Some(contents) = self.read(drive, file).await { + remote.extend(contents.entries); + registers.push(contents.registers); readable.push(file); } } @@ -355,19 +384,22 @@ impl WatchSync { // less than it holds, and read again next time. let mut own_unreadable = false; if self.own.lock().is_none() { - let entries = match &own_file { + let contents = match &own_file { Some(file) => self.read(drive, file).await, - None => Some(Vec::new()), + None => Some(Contents::default()), }; - match entries { - Some(entries) => { - remote.extend(entries.iter().cloned()); - *self.own.lock() = Some(entries); + match contents { + Some(contents) => { + remote.extend(contents.entries.iter().cloned()); + registers.push(contents.registers.clone()); + *self.own.lock() = Some(contents); } None => own_unreadable = true, } } + let (registers, profile_changes) = profile.sync(®isters, now)?; + let incoming: Vec<(String, String, WatchState)> = remote .iter() .filter(|entry| entry.is_sane()) @@ -388,15 +420,17 @@ impl WatchSync { // A file that is missing is written even when what it would hold has // not changed: another installation retired it, or it was deleted by // hand, and either way the history it carried lives only here now. - let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries); - let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none()) - { + let contents = Contents { entries, registers }; + let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&contents); + let empty = contents.entries.is_empty() && contents.registers.is_empty(); + let uploaded = if own_unreadable || unchanged || (empty && own_file.is_none()) { false } else { let file = HistoryFile { version: FORMAT_VERSION, installation: self.installation.clone(), - entries: entries.clone(), + entries: contents.entries.clone(), + registers: contents.registers.clone(), }; let bytes = serde_json::to_vec(&file) .map_err(|e| Error::Config(format!("serialize watch history: {e}")))?; @@ -404,7 +438,7 @@ impl WatchSync { Some(file) => drive.replace(&file.uid, &bytes).await?, None => drive.create(&folders[0], &own_name, &bytes).await?, } - *self.own.lock() = Some(entries); + *self.own.lock() = Some(contents); true }; @@ -435,34 +469,31 @@ impl WatchSync { uploaded, devices: others.len(), retired: retired.len(), + profile: profile_changes, }) } - /// The entries of one history file, from the cache when its revision has + /// The contents of one history file, from the cache when its revision has /// not moved, or `None` when it cannot be read. /// /// A file that will not download or parse is logged and skipped: it is /// another device's, and one bad file must not stop the rest syncing. - async fn read<D: HistoryDrive>( - &self, - drive: &D, - file: &RemoteFile, - ) -> Option<Vec<HistoryEntry>> { + async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> { let key = file.uid.link_id.as_str().to_owned(); if let Some(seen) = self.seen.lock().get(&key) && file.revision.is_some() && seen.revision == file.revision { - return seen.entries.clone(); + return seen.contents.clone(); } let uid = &file.uid; - let entries = match drive.download(uid).await { + let contents = match drive.download(uid).await { Ok(bytes) => { - let entries = parse(&bytes); - if entries.is_none() { + let contents = parse(&bytes); + if contents.is_none() { tracing::warn!(%uid, "skipping a watch-history file this build cannot read"); } - entries + contents } Err(error) => { // Not cached: a download that failed is tried again next time. @@ -474,10 +505,10 @@ impl WatchSync { key, Seen { revision: file.revision.clone(), - entries: entries.clone(), + contents: contents.clone(), }, ); - entries + contents } /// The history folders, resolved once per account. @@ -491,10 +522,13 @@ impl WatchSync { } } -/// A history file's entries, or `None` for one this build cannot read. -fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> { +/// A history file's contents, or `None` for one this build cannot read. +fn parse(bytes: &[u8]) -> Option<Contents> { let file: HistoryFile = serde_json::from_slice(bytes).ok()?; - (file.version <= FORMAT_VERSION).then_some(file.entries) + (file.version <= FORMAT_VERSION).then_some(Contents { + entries: file.entries, + registers: file.registers, + }) } /// The kind of device to register, which is only the icon the web app draws. @@ -518,6 +552,7 @@ fn new_installation_id() -> Result<String> { #[cfg(test)] mod tests { use super::*; + use crate::profile::tests::Device; fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry { HistoryEntry { @@ -655,11 +690,8 @@ mod tests { } /// An installation: its sync and its catalog. - fn device(id: &str) -> (WatchSync, Mutex<Catalog>) { - ( - WatchSync::with_installation(id.to_owned()), - Mutex::new(Catalog::in_memory().unwrap()), - ) + fn device(id: &str) -> (WatchSync, Device) { + (WatchSync::with_installation(id.to_owned()), Device::new(id)) } fn watched_at(position_secs: f64, updated_at: i64) -> WatchState { @@ -684,11 +716,14 @@ mod tests { .unwrap(); let first = laptop - .sync_with(&drive, &laptop_catalog, 100) + .sync_with(&drive, &laptop_catalog.profile(), 100) .await .unwrap(); assert!(first.uploaded); - let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap(); + let second = phone + .sync_with(&drive, &phone_catalog.profile(), 110) + .await + .unwrap(); assert_eq!(second.applied, 1); assert_eq!(second.devices, 1); @@ -714,12 +749,15 @@ mod tests { .unwrap(); laptop - .sync_with(&drive, &laptop_catalog, 300) + .sync_with(&drive, &laptop_catalog.profile(), 300) + .await + .unwrap(); + phone + .sync_with(&drive, &phone_catalog.profile(), 300) .await .unwrap(); - phone.sync_with(&drive, &phone_catalog, 300).await.unwrap(); laptop - .sync_with(&drive, &laptop_catalog, 300) + .sync_with(&drive, &laptop_catalog.profile(), 300) .await .unwrap(); @@ -740,8 +778,14 @@ mod tests { .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .unwrap(); - laptop.sync_with(&drive, &catalog, 100).await.unwrap(); - let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap(); + laptop + .sync_with(&drive, &catalog.profile(), 100) + .await + .unwrap(); + let again = laptop + .sync_with(&drive, &catalog.profile(), 200) + .await + .unwrap(); assert!(!again.uploaded); assert_eq!(drive.uploads(), 1); @@ -751,7 +795,10 @@ mod tests { async fn nothing_is_uploaded_before_anything_was_watched() { let drive = FakeDrive::default(); let (laptop, catalog) = device("laptop"); - let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + let report = laptop + .sync_with(&drive, &catalog.profile(), 100) + .await + .unwrap(); assert!(!report.uploaded); assert!(drive.names().is_empty()); } @@ -764,11 +811,17 @@ mod tests { .lock() .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .unwrap(); - laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + laptop + .sync_with(&drive, &catalog.profile(), 100) + .await + .unwrap(); let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect(); drive.trash(&own).await.unwrap(); - let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap(); + let report = laptop + .sync_with(&drive, &catalog.profile(), 200) + .await + .unwrap(); assert!(report.uploaded); assert_eq!(drive.names(), ["laptop.json"]); @@ -782,11 +835,17 @@ mod tests { .lock() .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .unwrap(); - laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + laptop + .sync_with(&drive, &catalog.profile(), 100) + .await + .unwrap(); // The catalog was deleted; the config, and with it the id, was not. let (relaunched, empty) = device("laptop"); - let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap(); + let report = relaunched + .sync_with(&drive, &empty.profile(), 200) + .await + .unwrap(); assert_eq!(report.applied, 1); assert!(!report.uploaded); @@ -804,7 +863,10 @@ mod tests { .lock() .set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .unwrap(); - laptop.sync_with(&drive, &catalog, 100).await.unwrap(); + laptop + .sync_with(&drive, &catalog.profile(), 100) + .await + .unwrap(); let (relaunched, other) = device("laptop"); other @@ -814,13 +876,19 @@ mod tests { drive .offline .store(true, std::sync::atomic::Ordering::SeqCst); - let report = relaunched.sync_with(&drive, &other, 200).await.unwrap(); + let report = relaunched + .sync_with(&drive, &other.profile(), 200) + .await + .unwrap(); assert!(!report.uploaded); drive .offline .store(false, std::sync::atomic::Ordering::SeqCst); - relaunched.sync_with(&drive, &other, 300).await.unwrap(); + relaunched + .sync_with(&drive, &other.profile(), 300) + .await + .unwrap(); let state = other.lock().all_watch_states().unwrap(); assert_eq!(state.len(), 2, "both episodes survive: {state:?}"); } @@ -832,6 +900,7 @@ mod tests { version: FORMAT_VERSION + 1, installation: "future".to_owned(), entries: vec![entry("ep1", 60.0, 100)], + registers: Registers::new(), }; drive.put("future.json", serde_json::to_vec(&future).unwrap()); let (laptop, catalog) = device("laptop"); @@ -841,7 +910,7 @@ mod tests { .unwrap(); let report = laptop - .sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY) + .sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY) .await .unwrap(); @@ -860,21 +929,24 @@ mod tests { .unwrap(); drive.at(100); old_phone - .sync_with(&drive, &old_catalog, 100) + .sync_with(&drive, &old_catalog.profile(), 100) .await .unwrap(); let (phone, catalog) = device("phone"); let later = 100 + RETIRE_AFTER_SECS; drive.at(later); - let report = phone.sync_with(&drive, &catalog, later).await.unwrap(); + let report = phone + .sync_with(&drive, &catalog.profile(), later) + .await + .unwrap(); assert_eq!(report.retired, 1); assert_eq!(drive.names(), ["phone.json"]); // Nothing was lost: the newcomer's file carries it now. let (fresh, fresh_catalog) = device("fresh"); fresh - .sync_with(&drive, &fresh_catalog, later) + .sync_with(&drive, &fresh_catalog.profile(), later) .await .unwrap(); assert_eq!( @@ -893,13 +965,13 @@ mod tests { .unwrap(); drive.at(100); laptop - .sync_with(&drive, &laptop_catalog, 100) + .sync_with(&drive, &laptop_catalog.profile(), 100) .await .unwrap(); let (phone, catalog) = device("phone"); let report = phone - .sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY) + .sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY) .await .unwrap(); @@ -925,9 +997,13 @@ mod tests { version: FORMAT_VERSION, installation: "abc".to_owned(), entries: vec![entry("a", 12.5, 100)], + registers: Registers::new(), }; let bytes = serde_json::to_vec(&file).unwrap(); - assert_eq!(parse(&bytes), Some(file.entries)); + assert_eq!( + parse(&bytes).map(|contents| contents.entries), + Some(file.entries) + ); } #[test] @@ -936,6 +1012,7 @@ mod tests { version: FORMAT_VERSION + 1, installation: "abc".to_owned(), entries: vec![entry("a", 12.5, 100)], + registers: Registers::new(), }; assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None); assert_eq!(parse(b"not json"), None); diff --git a/docs/ANDROID.md b/docs/ANDROID.md index d1b9e84..3baf4f5 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -55,15 +55,15 @@ installs the signed minified APK and runs instrumentation against it, which is the only path that exercises R8 — the step that has broken this app five distinct ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*` variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation, -process recreation, playback and background controls, Picture-in-Picture, +process recreation, playback and background controls, download cancellation/resume, and an offline launch with networking disabled — is `scripts/android-acceptance.sh`; `--list` prints every case and what it needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share, given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they reach the device as instrumentation arguments and are stored nowhere. Without -them those cases report `skip`. `picture-in-picture` is still `pending`, so the -run tells you the matrix is incomplete — but no longer because of B40, which is -fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer +them those cases report `skip`. There is no Picture-in-Picture: it was removed +after a PiP window that never sized its video right proved less use than +background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer detail, including why lint runs against a baseline. Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not @@ -110,6 +110,6 @@ ABIs, plus a source archive and revision record, under packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native memory. `PlaybackService` owns the mpv core for background audio and media -controls, while the activity supplies the current `Surface` and enters PiP. +controls, while the activity supplies the current `Surface`. Do not publish an Android binary until this exact pipeline is verified on real arm64 hardware and an x86_64 emulator. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b12ce9..56459a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,6 +118,18 @@ an edge case. including trashing the file of an installation quiet for 90 days once its history is merged into this one's. Folders of the account added as shares are only ever read. +- The same file carries the rest of what makes every device look alike + (`pstr-core::profile`): the shares, with each link and custom password so + another device can open them; the playback, appearance and metadata settings + except volume and mute; the TMDB key; hand-picked matches; per-show track + choices. Each is a register merged per key, newer wins, a removed share being + a register with no value. `sync-state.json` holds the registers as last + synced, and a sync stamps whatever differs from it, so nothing that writes a + setting has to know about sync. A first sync stamps what it finds at zero, so + a fresh install's defaults never outrank the library everyone else agreed on. + Automatic matches are not synced: every device with enrichment on finds them + again. The front end crawls what arrived (`uncrawled_shares` covers a sync + that ran in the background) and drops what was removed with its files. - A CAPTCHA in front of a sign-in is shown in a webview, because the page reports success by posting a message to its host. On the desktop that is a child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux, @@ -139,7 +151,8 @@ an edge case. | Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive | | Proton session | OS credential store, `proton-account` | No — sign in again | | Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read | -| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again | +| Sync state | config dir, `sync-state.json` | Yes — a missing one is a first sync, which takes everything other devices have | +| Watch history and profile in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode and per register, newer wins. Holds share links and passwords, end-to-end encrypted by Drive. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again | | Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | | Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px | diff --git a/docs/IOS.md b/docs/IOS.md index 487129a..f0e8048 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -75,7 +75,7 @@ Each of these is the platform's doing, not a choice to diverge. boundary and is queued again; it resumes from its `.part` file when the app next comes forward. "Download on Wi-Fi only" stops transfers on a cellular or otherwise expensive network rather than holding them back. -- **Watch history syncs when the app leaves the screen**, inside the same grace, in +- **Sync runs when the app leaves the screen**, inside the same grace, in place of Android's `WatchSyncWorker`, and every five minutes while it is open. - **Background audio off pauses** the episode when the app leaves the screen, where Android stops it. diff --git a/docs/TESTING.md b/docs/TESTING.md index 0f5d0e0..839436c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -255,10 +255,9 @@ three dispositions, and the third is the one to read: | `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone | | `skip` | needs a share that was not configured | -Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is -`pending` against B40 — asserting today's behaviour would encode the bug rather -than catch it. **The matrix is not complete, and the runner says so on every -run.** +Today `process-recreation` is `xfail` against B35 — asserting today's behaviour +would encode the bug rather than catch it. The `picture-in-picture` case went +with Picture-in-Picture itself, so nothing is `pending`. The five live cases need a share, given as environment variables that reach the device as instrumentation arguments and are written nowhere: diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index a0dcf36..cc72bda 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -52,12 +52,18 @@ final class AppModel { var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0) /// What the snackbar says. var message: String? + /// 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. + private(set) var activity: String? /// What Undo on `message` puts back, when the message offers one. var undo: [WatchSnapshot]? var account = AccountUiState() @ObservationIgnored private var searchTask: Task<Void, Never>? @ObservationIgnored private var started = false + /// Launch and the first `.active` both ask; one crawl answers. + @ObservationIgnored private var catchingUp = false /// Five minutes, as on Android: cheap when nothing changed — one folder /// listing, and no upload. @@ -67,6 +73,7 @@ final class AppModel { if started { return } started = true reload() + enteredForeground() DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() } DownloadCoordinator.shared.resumeQueued() Task { @@ -126,46 +133,95 @@ final class AppModel { /// The crawl itself, for pull to refresh, which holds its spinner until /// this returns. func crawlNow(_ shareId: String?) async { - refreshing = true - message = nil - do { - try await NativeRuntime.engine().crawl(shareId: shareId) - } catch { - message = errorMessage(error) + let name = shareId.flatMap { id in shares.first { $0.id == id }?.name } + await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") { + message = nil + do { + try await NativeRuntime.engine().crawl(shareId: shareId) + } catch { + message = errorMessage(error) + } } - refreshing = false reload() } /// Run enrichment again. With `force` every title is looked up afresh, /// including ones that already matched. func matchTitles(force: Bool) { - Task { - refreshing = true - message = nil + Task { await match(force: force) } + } + + private func match(force: Bool) async { + await busy("Looking up titles…") { do { let summary = try await NativeRuntime.engine().matchTitles(force: force) message = Self.describe(summary) } catch { message = errorMessage(error) } - refreshing = false - reload() } + reload() + } + + /// Say `what` in the activity banner while `work` runs. Nested work keeps + /// the outer line once the inner one is done. + private func busy(_ what: String, _ work: () async -> Void) async { + let outer = activity + activity = what + refreshing = true + await work() + activity = outer + refreshing = outer != nil } // MARK: - Shares - func addShare(name: String, url: String, password: String?) { - Task { - do { - let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } - _ = try await run { try $0.addShare(name: name, url: url, customPassword: password) } - try await NativeRuntime.engine().crawl(shareId: nil) - } catch { - message = errorMessage(error) + /// 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. + func addShare(name: String, url: String, password: String?) async -> String? { + let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + let share: ShareRecord + do { + share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password) + } catch { + return errorMessage(error) + } + reload() + Task { await crawlAdded([share.id], announce: true) } + return nil + } + + /// Crawl shares that are new here — added on this device or by sync — + /// then match them when enrichment is on. + private func crawlAdded(_ ids: [String], announce: Bool) async { + guard !ids.isEmpty else { return } + let before = Set(titles.map(\.key)) + var failed: [String] = [] + for id in ids { + let name = shares.first { $0.id == id }?.name ?? "the new share" + await busy("Reading \(name)…") { + do { + try await NativeRuntime.engine().crawl(shareId: id) + } catch { + failed.append("\(name): \(errorMessage(error))") + } } - reload() + } + await reloadNow() + if !failed.isEmpty { + message = failed.joined(separator: "\n") + return + } + if metadataSettings.enabled { + await match(force: false) + } + if announce { + let added = Set(titles.map(\.key)).subtracting(before).count + message = added == 1 ? "1 title added" : "\(added) titles added" } } @@ -283,16 +339,18 @@ final class AppModel { /// Sync now, from a tap: says so when it is done. func syncNow() { Task { - guard await syncWatchHistory() else { return } + var synced = false + await busy("Syncing with your other devices…") { synced = await syncWatchHistory() } + guard synced else { return } let applied = account.applied - message = applied > 0 ? "Watch history synced, \(applied) from other devices" : "Watch history is up to date" + message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date" } } - /// Pull other devices' positions in and push this one's out. Quiet: it runs - /// on a timer, and a failure is shown on the account card rather than as a - /// snackbar every five minutes of an offline evening. Returns whether it - /// synced. + /// Pull other devices' positions, shares and settings in and push this + /// one's out. Quiet: it runs on a timer, and a failure is shown on the + /// account card rather than as a snackbar every five minutes of an offline + /// evening. Returns whether it synced. @discardableResult func syncWatchHistory() async -> Bool { guard case .signedIn = account.state else { return false } @@ -301,7 +359,7 @@ final class AppModel { account.syncedAt = Date() account.applied = Int(report.applied) account.syncError = nil - if report.applied > 0 { reload() } + await takeSynced(report) return true } catch { let state = try? await run { $0.accountState() } @@ -314,6 +372,40 @@ final class AppModel { } } + /// What another device changed: its shares crawled or dropped here, its + /// settings repainted. + private func takeSynced(_ report: SyncRecord) async { + for id in report.sharesRemoved { + await DownloadCoordinator.shared.cancelShare(id) + DownloadStateStore.shared.removeShare(id) + } + if report.settingsChanged { + await AppearanceState.shared.reload() + } + if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty { + await reloadNow() + } + // A crawl takes longer than the grace iOS gives a backgrounded app. + // What is left uncrawled is picked up on the way back. + if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background { + await crawlAdded(report.sharesAdded, announce: false) + message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices" + } + } + + /// On launch and back on screen: crawl shares the catalog has nothing of — + /// brought by a sync that ran in the background, or an add whose crawl + /// never finished. + func enteredForeground() { + guard !catchingUp else { return } + catchingUp = true + Task { + defer { catchingUp = false } + guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return } + await crawlAdded(ids, announce: false) + } + } + /// Leaving the app: push where playback stopped, inside the grace iOS /// gives a backgrounded app — what `WatchSyncWorker` does on Android. func syncInBackground() { @@ -333,15 +425,13 @@ final class AppModel { func addAccountFolder(name: String, volumeId: String, linkId: String) { Task { - refreshing = true do { let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) } - try await NativeRuntime.engine().crawl(shareId: share.id) + reload() + await crawlAdded([share.id], announce: true) } catch { reportError(error) } - refreshing = false - reload() } } @@ -437,24 +527,29 @@ final class AppModel { // MARK: - Metadata - func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) { - Task { - do { - let engine = try await NativeRuntime.engine() - try await run { engine in - if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { - try engine.setMetadataApiKey(provider: provider, key: apiKey) - } - try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true)) + /// 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. + /// Before, the page only changed once that run had finished, so a save + /// looked like it had done nothing. + func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? { + do { + metadataSettings = try await run { engine in + if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { + try engine.setMetadataApiKey(provider: provider, key: apiKey) } - if enabled { - message = Self.describe(try await engine.matchTitles(force: false)) - } - } catch { - reportError(error) + try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true)) + return try engine.metadataSettings() } - reload() + } catch { + return errorMessage(error) } + await reloadNow() + if enabled { + Task { await match(force: false) } + } + return nil } func reloadAfterMetadataChange() { reload() } @@ -578,36 +673,39 @@ final class AppModel { // MARK: - Reload func reload() { + Task { await reloadNow() } + } + + /// `reload`, for a caller that goes on to read what it loaded. + func reloadNow() async { let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query let sort = sort let grouped = grouped - Task { - do { - let result = try await run { engine in - // 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. - _ = try engine.pruneOfflineFiles() - return Reloaded( - shares: try engine.shares(), - titles: try engine.library(search: query), - arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped), - offline: try engine.offlineFiles(), - metadataSettings: try engine.metadataSettings(), - storage: try engine.storageUsage() - ) - } - loading = false - shares = result.shares - titles = result.titles - arrangement = result.arrangement - offline = result.offline - metadataSettings = result.metadataSettings - storage = result.storage - } catch { - loading = false - message = errorMessage(error) + do { + let result = try await run { engine in + // 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. + _ = try engine.pruneOfflineFiles() + return Reloaded( + shares: try engine.shares(), + titles: try engine.library(search: query), + arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped), + offline: try engine.offlineFiles(), + metadataSettings: try engine.metadataSettings(), + storage: try engine.storageUsage() + ) } + loading = false + shares = result.shares + titles = result.titles + arrangement = result.arrangement + offline = result.offline + metadataSettings = result.metadataSettings + storage = result.storage + } catch { + loading = false + message = errorMessage(error) } } diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 56265fe..e40b941 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -99,8 +99,12 @@ struct RootView: View { } } } - Snackbar() - .padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0)) + VStack(spacing: 8) { + ActivityBanner() + Snackbar() + } + .padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0)) + .animation(.easeInOut(duration: 0.2), value: model.activity) } .background(scheme.background.ignoresSafeArea()) } @@ -140,6 +144,7 @@ struct RootView: View { model.syncInBackground() case .active: host.enteredForeground() + model.enteredForeground() DownloadCoordinator.shared.resumeQueued() default: break @@ -301,6 +306,33 @@ private struct NavigationRail: View { } } +/// 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. +private struct ActivityBanner: View { + @Environment(AppModel.self) private var model + @Environment(\.scheme) private var scheme + + var body: some View { + if let activity = model.activity { + HStack(spacing: 12) { + ProgressView().controlSize(.small).tint(scheme.primary) + Text(activity) + .textStyle(.bodyMedium) + .foregroundStyle(scheme.onSurface) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .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) + .accessibilityAddTraits(.updatesFrequently) + } + } +} + /// The app's one line of feedback: what just happened, with Undo where it can /// be taken back. private struct Snackbar: View { diff --git a/ios/ProtonStream/Player/MpvPlayer.swift b/ios/ProtonStream/Player/MpvPlayer.swift index 98421c7..8c80c32 100644 --- a/ios/ProtonStream/Player/MpvPlayer.swift +++ b/ios/ProtonStream/Player/MpvPlayer.swift @@ -109,6 +109,9 @@ final class MpvPlayer { private init() { layer.framebufferOnly = true layer.backgroundColor = UIColor.black.cgColor + // For the frames between a resize and mpv's next one: a drawable of + // the old shape is letterboxed rather than stretched. + layer.contentsGravity = .resizeAspect guard let mpv = mpv_create() else { return } // mpv takes the layer's address as an int64, not a Swift reference. var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque())) diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index ddc4734..8b3c8fb 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -567,9 +567,9 @@ struct MpvView: UIViewRepresentable { func updateUIView(_: LayerView, context _: Context) {} final class LayerView: UIView { - private let video: CALayer + private let video: CAMetalLayer - init(layer video: CALayer) { + init(layer video: CAMetalLayer) { self.video = video super.init(frame: .zero) backgroundColor = .black @@ -580,14 +580,30 @@ struct MpvView: UIViewRepresentable { @available(*, unavailable) required init?(coder _: NSCoder) { nil } + /// The drawable is sized here, not left to the layer. MoltenVK sets + /// `drawableSize` once when it builds the swapchain and Core Animation + /// stops tracking the bounds from then on, while mpv decides its + /// output size from `drawableSize` alone. Left alone, a rotation, a + /// Split View resize or the player reopening at another size kept the + /// old drawable, which Core Animation stretched over the new bounds — + /// a squashed or cropped picture until something else resized it. override func layoutSubviews() { super.layoutSubviews() + let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale CATransaction.begin() CATransaction.setDisableActions(true) video.frame = bounds - video.contentsScale = window?.screen.scale ?? UIScreen.main.scale + video.contentsScale = scale + video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale) CATransaction.commit() } + + override func didMoveToWindow() { + super.didMoveToWindow() + // Reattached after the player was minimised: the layer is the one + // from before, still sized for wherever it last was. + setNeedsLayout() + } } } diff --git a/ios/ProtonStream/UI/AccountUI.swift b/ios/ProtonStream/UI/AccountUI.swift index 982b970..86c951a 100644 --- a/ios/ProtonStream/UI/AccountUI.swift +++ b/ios/ProtonStream/UI/AccountUI.swift @@ -44,7 +44,7 @@ struct AccountCard: View { } else { VStack(alignment: .leading, spacing: 8) { Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface) - 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.") + Text("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.") .textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) Button("Sign in", action: onSignIn).buttonStyle(.tonal) } @@ -61,8 +61,8 @@ struct AccountCard: View { /// "Synced 3 min ago · 2 from other devices", or why it did not. func syncLine(_ account: AccountUiState, now: Date) -> String { - if let error = account.syncError { return "Watch history did not sync: \(error)" } - guard let at = account.syncedAt else { return "Watch history syncs with your Drive" } + if let error = account.syncError { return "Did not sync: \(error)" } + guard let at = account.syncedAt else { return "Library, settings and watch history sync through your Drive" } let minutes = Int(max(now.timeIntervalSince(at), 0) / 60) let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago" let applied = account.applied > 0 ? " · \(account.applied) from other devices" : "" diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 52917e6..4fdc0ac 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -63,8 +63,15 @@ struct SettingsView: View { } } } - .task { - prefs = try? await model.run { try $0.playbackPrefs() } + // Read again whenever the list is shown, not once: sync can rewrite + // them while the page is open, and a page holding the old copy would + // write it back over the new one on the next switch. + .task(id: page) { + do { + prefs = try await model.run { try $0.playbackPrefs() } + } catch { + model.reportError(error) + } } .alert("Delete all offline episodes?", isPresented: $confirmDelete) { Button("Delete", role: .destructive, action: model.removeAllOffline) @@ -75,8 +82,7 @@ struct SettingsView: View { .sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) } .sheet(isPresented: $showMetadata) { MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in - model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key) - showMetadata = false + await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key) } } } @@ -216,12 +222,23 @@ struct SettingsView: View { Binding(get: { value }, set: { on in update { change(&$0, on) } }) } + /// Change a preference on screen at once, and store it. A store that + /// fails says so and puts the switch back, rather than leaving one that + /// looks saved and is not. private func update(_ change: (inout PlaybackPrefsRecord) -> Void) { guard var next = prefs else { return } + let before = next change(&next) prefs = next let stored = next - Task.detached { try? NativeRuntime.blockingEngine().setPlaybackPrefs(prefs: stored) } + Task { + do { + try await model.run { try $0.setPlaybackPrefs(prefs: stored) } + } catch { + if prefs == stored { prefs = before } + model.reportError(error) + } + } } } @@ -258,15 +275,18 @@ private struct SettingAction: View { private struct MetadataSettingsSheet: View { let current: MetadataSettingsRecord - let onSave: (Bool, MetadataProvider, String, String) -> Void + /// Resolves to why the settings were not stored, or nil once they are. + let onSave: (Bool, MetadataProvider, String, String) async -> String? @Environment(\.dismiss) private var dismiss @Environment(\.scheme) private var scheme @State private var enabled: Bool @State private var provider: MetadataProvider @State private var language: String @State private var apiKey = "" + @State private var saving = false + @State private var refusal: String? - init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) -> Void) { + init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) async -> String?) { self.current = current self.onSave = onSave _enabled = State(initialValue: current.enabled) @@ -290,14 +310,27 @@ private struct MetadataSettingsSheet: View { Field(label: "Language", text: $language) Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true) } + if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty { + Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error) + } + if let refusal { + Text(refusal).foregroundStyle(scheme.error) + } } buttons: { Button("Cancel") { dismiss() }.buttonStyle(.tonal) - Button("Save") { - onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey) + Button(saving ? "Saving…" : "Save") { + saving = true + refusal = nil + Task { + refusal = await onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey) + saving = false + if refusal == nil { dismiss() } + } } .buttonStyle(.accent) - .disabled(!(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty)) + .disabled(saving || !(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty)) } + .interactiveDismissDisabled(saving) .secureContent() } } @@ -408,9 +441,12 @@ private struct AppearancePicker: View { } } + /// Selected at once, so a second tap builds on the first rather than on + /// what was there before it, and painted when Rust has resolved it. private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) { guard var next = choice else { return } edit(&next) + choice = next let chosen = next Task { await repaint(chosen, store: true) @@ -420,20 +456,29 @@ private struct AppearancePicker: View { private func repaint(_ next: AppearanceRecord, store: Bool) async { let accents = Self.accents - guard let (palette, row) = try? await model.run({ engine -> (PaletteRecord, [AccentChoice: UInt32]) in - if store { try engine.setAppearance(appearance: next) } - let palette = engine.previewPalette(appearance: next) - // Every accent as it would look in *this* flavour: a swatch row that - // keeps Mocha's pastels while Latte is selected lies about what the - // next tap does. - var row: [AccentChoice: UInt32] = [:] - for accent in accents { - var variant = next - variant.accent = accent - row[accent] = engine.previewPalette(appearance: variant).accent + let resolved: (PaletteRecord, [AccentChoice: UInt32]) + do { + resolved = try await model.run { engine -> (PaletteRecord, [AccentChoice: UInt32]) in + if store { try engine.setAppearance(appearance: next) } + let palette = engine.previewPalette(appearance: next) + // Every accent as it would look in *this* flavour: a swatch row that + // keeps Mocha's pastels while Latte is selected lies about what the + // next tap does. + var row: [AccentChoice: UInt32] = [:] + for accent in accents { + var variant = next + variant.accent = accent + row[accent] = engine.previewPalette(appearance: variant).accent + } + return (palette, row) } - return (palette, row) - }) else { return } + } catch { + model.reportError(error) + return + } + // A later tap has already moved on; its own repaint is on the way. + if store, choice != next { return } + let (palette, row) = resolved choice = next swatches = row.mapValues(argb) AppearanceState.shared.apply(palette) diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index 5c88e76..f491f44 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -57,7 +57,7 @@ struct SharesView: View { } .sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) { AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in - model.addShare(name: name, url: url, password: password) + await model.addShare(name: name, url: url, password: password) } } .sheet(isPresented: Binding( @@ -166,18 +166,29 @@ private struct RepairShareSheet: View { } } +/// 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. private struct AddShareSheet: View { - let onAdd: (String, String, String?) -> Void + /// Resolves to why the link was refused, or nil once it is added. + let onAdd: (String, String, String?) async -> String? @Environment(\.dismiss) private var dismiss + @Environment(\.scheme) private var scheme @State private var name = "" @State private var url: String @State private var password = "" + @State private var adding = false + @State private var refusal: String? - init(initialUrl: String, onAdd: @escaping (String, String, String?) -> Void) { + init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) { self.onAdd = onAdd _url = State(initialValue: initialUrl) } + private var ready: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty + } + var body: some View { DialogSheet("Add Proton Drive share") { Field(label: "Library name", text: $name) @@ -195,15 +206,29 @@ private struct AddShareSheet: View { .buttonStyle(.quiet) } Field(label: "Custom password (optional)", text: $password, secret: true) + if adding { + HStack(spacing: 12) { + ProgressView().controlSize(.small).tint(scheme.primary) + Text("Opening the link…") + } + } else if let refusal { + Text(refusal).foregroundStyle(scheme.error) + } } buttons: { Button("Cancel") { dismiss() }.buttonStyle(.tonal) - Button("Add") { - onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password) - dismiss() + Button(adding ? "Adding…" : "Add") { + adding = true + refusal = nil + Task { + refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password) + adding = false + if refusal == nil { dismiss() } + } } .buttonStyle(.accent) - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(!ready || adding) } + .interactiveDismissDisabled(adding) .secureContent() } } diff --git a/ios/ProtonStream/UI/Theme/Theme.swift b/ios/ProtonStream/UI/Theme/Theme.swift index 298ad79..489ea74 100644 --- a/ios/ProtonStream/UI/Theme/Theme.swift +++ b/ios/ProtonStream/UI/Theme/Theme.swift @@ -33,6 +33,13 @@ final class AppearanceState { } } + /// Read the stored choice again, after sync rewrote it. + func reload() async { + if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value { + palette = read + } + } + /// Repaint immediately, before the write that stores the choice lands. func apply(_ palette: PaletteRecord) { self.palette = palette diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index a857bce..610e681 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -465,6 +465,9 @@ struct ChangeMatchSheet: View { // Choosing fetches the entry's episodes too, which is a few requests. @State private var choosing = false @State private var options: [MatchRecord] = [] + /// Said here rather than in the snackbar, which this sheet covers. + @State private var failure: String? + @State private var searched = false init(title: TitleRecord, onDone: @escaping () -> Void) { self.title = title @@ -481,7 +484,17 @@ struct ChangeMatchSheet: View { .buttonStyle(.accent) .disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching) if choosing { - Text("Fetching its episodes…").textStyle(.bodySmall).padding(.top, 8) + HStack(spacing: 12) { + ProgressView().controlSize(.small).tint(scheme.primary) + Text("Fetching its episodes…").textStyle(.bodySmall) + } + .padding(.top, 8) + } + if let failure { + Text(failure).foregroundStyle(scheme.error) + } + if !searching, options.isEmpty, searched { + Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall) } ForEach(options, id: \.remoteId) { option in Button { choose(option) } label: { @@ -503,7 +516,7 @@ struct ChangeMatchSheet: View { try await model.run { try $0.forgetMatch(titleKey: title.key) } changed() } catch { - model.reportError(error) + failure = errorMessage(error) } } } @@ -516,24 +529,27 @@ struct ChangeMatchSheet: View { let term = term guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return } searching = true + failure = nil Task { do { options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term) } catch { - model.reportError(error) + failure = errorMessage(error) } searching = false + searched = true } } private func choose(_ option: MatchRecord) { choosing = true + failure = nil Task { do { try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option) changed() } catch { - model.reportError(error) + failure = errorMessage(error) } choosing = false } diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift index fb3cb94..7cfabff 100644 --- a/ios/ProtonStreamTests/UiLogicTests.swift +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -51,7 +51,7 @@ final class IncomingShareLinkTests: XCTestCase { final class AccountUiTests: XCTestCase { func testTheSyncLineSaysHowLongAgoAndWhatCameIn() { let zero = Date(timeIntervalSince1970: 0) - XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Watch history syncs with your Drive") + XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Library, settings and watch history sync through your Drive") XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now") XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago") XCTAssertEqual( @@ -64,7 +64,7 @@ final class AccountUiTests: XCTestCase { let zero = Date(timeIntervalSince1970: 0) XCTAssertEqual( syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero), - "Watch history did not sync: offline" + "Did not sync: offline" ) } diff --git a/scripts/android-acceptance.py b/scripts/android-acceptance.py index dbed21d..a4730b7 100755 --- a/scripts/android-acceptance.py +++ b/scripts/android-acceptance.py @@ -604,23 +604,6 @@ for _name, _what, _method in ( ): REGISTRY.append(Case(name=_name, what=_what, run=_live(_method), needs_share=True)) -# Still unimplemented, and the only case left that is. No longer blocked on the -# app: B40 landed, so there is correct behaviour to assert. What is missing is a -# driver — every live case here reads the engine and libmpv directly, and this -# one needs the activity with the player composed on screen before PiP means -# anything. -REGISTRY.append( - Case( - name="picture-in-picture", - what="entering PiP keeps playing and shows only video", - run=_pending( - "needs an activity-level driver: the live suite exercises the engine " - "and libmpv, not the composition, and PiP is a property of the window" - ), - needs_share=True, - ) -) - # -------------------------------------------------------------------------- # runner