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

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

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

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

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

No files matched your search

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