Compare commits

5 Commits
Author SHA1 Message Date
nvrl ce02bbc35a fix(vers) updated cargo + pkgbuild version 2026-10-12 19:10:58 +02:00
nvrl 0b9a9dab50 fix(ios,android): offer the right episode as up next while the next one opens
After "next episode" during the credits, the previous file keeps
playing until the new stream is open. Its chapters and position were
read against the already-advanced index, so the up-next card offered
the episode after the next one for those seconds. The card, the skip
offer and auto-skip now only act on readings of the open episode.
2026-10-12 18:36:33 +02:00
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00
nvrl 2f3be1d193 chore(version): bump to 1.4.0
Minor. Sync now carries the whole library, not only watch positions:
shares with their links and passwords, playback, appearance and metadata
settings, the TMDB key, hand-picked matches and per-title tracks, so every
signed-in device shows the same library. Devices share it once all of them
run 1.4.0; an older build keeps syncing watch positions and ignores the
rest.

Android drops picture-in-picture. iOS fixes the stretched player after a
rotation or resize, the share that failed to add on the first try, and
settings that saved late or silently. Both phone clients now show what is
running and why a form was refused.

No catalog schema change since 1.3.0.
2026-10-09 03:15:55 +02:00
nvrl 8e42906a7f 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.
2026-10-09 03:15:39 +02:00
47 changed files with 2716 additions and 702 deletions

No files matched your search

Generated
+7 -7
View File
@@ -5122,7 +5122,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5142,7 +5142,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5176,7 +5176,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5195,7 +5195,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5215,7 +5215,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5228,7 +5228,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5243,7 +5243,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.3.0" version = "1.4.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.3.0" version = "1.4.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
-1
View File
@@ -35,7 +35,6 @@
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"> android:resizeableActivity="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -1,35 +1,23 @@
package io.narl.protonstream package io.narl.protonstream
import android.Manifest 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.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager 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.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Rational
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore 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.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull 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 kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null) 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. */ /** A share link handed over by a browser or the share sheet, until the Add form takes it. */
private val incomingShareLink = mutableStateOf<String?>(null) 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( private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own // Before anything draws. The palette decides the window's own
@@ -116,34 +79,16 @@ class MainActivity : ComponentActivity() {
playbackConnection, playbackConnection,
Context.BIND_AUTO_CREATE, Context.BIND_AUTO_CREATE,
) )
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) 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 { setContent {
ProtonStreamTheme { ProtonStreamTheme {
ProtonStreamApp( ProtonStreamApp(
playerHost.value, playerHost.value,
inPictureInPicture.value,
shareLink = incomingShareLink.value, shareLink = incomingShareLink.value,
onShareLinkTaken = { incomingShareLink.value = null }, onShareLinkTaken = { incomingShareLink.value = null },
) )
@@ -178,27 +123,7 @@ class MainActivity : ComponentActivity() {
intent.shareLink()?.let { incomingShareLink.value = it } 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() { override fun onDestroy() {
runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) { if (bound) {
unbindService(playbackConnection) unbindService(playbackConnection)
bound = false bound = false
@@ -213,87 +138,9 @@ class MainActivity : ComponentActivity() {
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this) if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
if ( if (
!isChangingConfigurations && !isChangingConfigurations &&
!isInPictureInPictureMode &&
!SettingsStore(this).backgroundAudio !SettingsStore(this).backgroundAudio
) { ) {
playerHost.value?.stop() 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. * 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 media session and its notification have to name the episode, and
* the episode, and none of them can reach the composition that knows it. The * neither can reach the composition that knows it. The player publishes this
* player publishes this once per episode and they read it. * once per episode and they read it.
*/ */
data class NowPlaying( data class NowPlaying(
val media: String, 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. * Where a transport outside the player sends previous/next.
* *
* The player owns which episode is open, so the notification, the lock * The player owns which episode is open, so the notification and the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both * screen cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives. * run on the main thread, which is where the player's state lives.
*/ */
var onSkipPrevious: (() -> Unit)? = null var onSkipPrevious: (() -> Unit)? = null
@@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull 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() { class PlaybackService : Service() {
inner class PlaybackBinder : Binder() { inner class PlaybackBinder : Binder() {
val host: NativeMpvHost? get() = this@PlaybackService.host 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. * 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 * Not a dialog: a dialog window has its own insets and cannot be told to draw
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the * into the display cutout. Owning the activity window is what makes
* activity window is what makes "fullscreen" mean the whole panel, notch * "fullscreen" mean the whole panel, notch included.
* included.
* *
* [episodes] is the title's episodes in display order and [index] the one that is * [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 * 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 — * 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. * 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 @Composable
fun PlayerScreen( fun PlayerScreen(
@@ -150,7 +146,6 @@ fun PlayerScreen(
episodes: List<EpisodeRecord>, episodes: List<EpisodeRecord>,
index: Int, index: Int,
host: LibmpvHost?, host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean, minimized: Boolean,
onIndexChange: (Int) -> Unit, onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit, onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
@@ -294,9 +289,8 @@ fun PlayerScreen(
onIndexChange(target) onIndexChange(target)
} }
// What the media notification, the lock screen and Picture-in-Picture show. // What the media notification and the lock screen show. Neither can reach
// None of them can reach this composition, and all three have to name the // this composition, and both have to name the episode rather than the app.
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) { LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish( nativeHost.publish(
NowPlaying( NowPlaying(
@@ -339,7 +333,12 @@ fun PlayerScreen(
val problem by nativeHost.problem.collectAsState() val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } } LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) { // Whether mpv's readings are this episode's. Not while the next one opens:
// the last one's credits play on until it is loaded, and read against the
// new index they offered the episode after the next as up next.
val playingThis = state.media == key
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
if (!playingThis) return@remember null
runCatching { skipOffer(chapters, state.position) }.getOrNull() runCatching { skipOffer(chapters, state.position) }.getOrNull()
} }
// Auto-skip is offered once per chapter: seeking back into an opening on // Auto-skip is offered once per chapter: seeking back into an opening on
@@ -359,12 +358,6 @@ fun PlayerScreen(
showControls = false 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 if (minimized) return
Box( Box(
@@ -374,7 +367,6 @@ fun PlayerScreen(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
enabled = overlays,
onClick = { showControls = !showControls }, onClick = { showControls = !showControls },
), ),
) { ) {
@@ -393,7 +385,7 @@ fun PlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.TopStart), modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -450,7 +442,7 @@ fun PlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = overlays && showControls, visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
@@ -476,7 +468,7 @@ fun PlayerScreen(
// Drawn whether or not the rest of the controls are up: an opening lasts // 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 // ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time. // reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip -> offer?.takeIf { !autoSkip }?.let { skip ->
TonalButton( TonalButton(
onClick = { nativeHost.seek(skip.target) }, onClick = { nativeHost.seek(skip.target) },
modifier = Modifier modifier = Modifier
@@ -488,7 +480,7 @@ fun PlayerScreen(
UpNextCard( UpNextCard(
next = next, next = next,
visible = overlays && autoplay && !state.paused && visible = autoplay && !state.paused && playingThis &&
chapters.creditsStart?.let { state.position >= it } == true, chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() }, onPlayNow = { advance() },
modifier = Modifier modifier = Modifier
@@ -143,8 +143,9 @@ internal fun AccountCard(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium) Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text( Text(
"Optional. Signed in, watch history is kept in your own Drive so every device " + "Optional. Signed in, your shares, settings and watch history are kept in your own " +
"resumes where another left off, and folders of your Drive can join the library.", "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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -156,8 +157,8 @@ internal fun AccountCard(
/** "Synced 3 min ago · 2 from other devices", or why it did not. */ /** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String { internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Watch history did not sync: $it" } account.syncError?.let { return "Did not sync: $it" }
val at = account.syncedAt ?: return "Watch history syncs with your Drive" val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt() val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when { val ago = when {
minutes < 1 -> "just now" minutes < 1 -> "just now"
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AppearanceState
import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
@@ -38,6 +39,7 @@ import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome import uniffi.pstr_android.SignInOutcome
import uniffi.pstr_android.SyncRecord
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -64,6 +66,12 @@ data class AppUiState(
), ),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL), val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null, 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. */ /** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null, val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(), val account: AccountUiState = AccountUiState(),
@@ -99,6 +107,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private val mutableState = MutableStateFlow(AppUiState()) private val mutableState = MutableStateFlow(AppUiState())
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
val state: StateFlow<AppUiState> = mutableState.asStateFlow() val state: StateFlow<AppUiState> = mutableState.asStateFlow()
/** Launch and coming back both ask; one crawl answers. */
private var catchingUp = false
/**
* Every banner line whose work is still running, oldest first. Touched on
* the main thread only, as everything in [viewModelScope] is.
*/
private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L
init { init {
if (!NativeRuntime.tlsReady) { if (!NativeRuntime.tlsReady) {
@@ -107,6 +124,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
reload() reload()
catchUp()
viewModelScope.launch { viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } } val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut) .getOrDefault(AccountState.SignedOut)
@@ -116,6 +134,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
// and no upload. // and no upload.
while (isActive) { while (isActive) {
syncWatchHistory() syncWatchHistory()
// A sync WatchSyncWorker ran while the app was away may have
// brought shares this one has not crawled.
catchUp()
delay(SYNC_INTERVAL_MS) delay(SYNC_INTERVAL_MS)
} }
} }
@@ -144,14 +165,51 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } busy("Refreshing the library…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } mutableState.update { it.copy(message = null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
mutableState.update { it.copy(refreshing = false) } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
reload() }
reloadNow()
matchAfterCrawl()
} }
} }
/**
* Look up whatever a crawl brought that has no answer yet: a new show, or
* a new episode its stored guide cannot name. When everything already has
* one the bridge asks nothing, so this is cheap after every crawl.
*/
private suspend fun matchAfterCrawl() {
val settings = mutableState.value.metadataSettings
if (settings.enabled && settings.ready) match(force = false, quiet = true)
}
/**
* Say [what] in the activity banner while [work] runs.
*
* The banner shows the newest line still running. Work overlaps — a sync
* starts during a crawl and ends after it — so each line is removed by
* itself rather than by putting back whatever was showing before, which
* once brought back a finished crawl's line and spun it forever.
*/
private suspend fun busy(what: String, work: suspend () -> Unit) {
val token = nextActivity++
activities += token to what
showActivity()
try {
work()
} finally {
activities.removeAll { it.first == token }
showActivity()
}
}
private fun showActivity() {
val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
}
/** /**
* Recrawl one share rather than the whole library. * Recrawl one share rather than the whole library.
* *
@@ -162,11 +220,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
*/ */
fun refreshShare(id: String) { fun refreshShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) } val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } busy("Reading $name…") {
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } mutableState.update { it.copy(message = null) }
mutableState.update { it.copy(refreshing = false) } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
reload() .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
}
reloadNow()
matchAfterCrawl()
} }
} }
@@ -179,26 +240,90 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
* changed. * changed.
*/ */
fun matchTitles(force: Boolean) { fun matchTitles(force: Boolean) {
viewModelScope.launch { viewModelScope.launch { match(force) }
mutableState.update { it.copy(refreshing = true, message = null) } }
/**
* With [quiet], a run nobody asked for says nothing when it found nothing
* to do.
*/
private suspend fun match(force: Boolean, quiet: Boolean = false) {
busy("Looking up titles…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } } runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } } .onSuccess { summary ->
if (!quiet || !summary.isEmpty()) {
mutableState.update { it.copy(message = describe(summary)) }
}
}
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) } }
reload() reloadNow()
}
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
/**
* 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
}
matchAfterCrawl()
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 { viewModelScope.launch {
runCatching { try {
withContext(Dispatchers.IO) { val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
val engine = NativeRuntime.engine() .getOrDefault(emptyList())
engine.addShare(name, url, password?.takeIf(String::isNotBlank)) crawlAdded(ids, announce = false)
engine.crawl(null) } finally {
} catchingUp = false
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }
reload()
} }
} }
@@ -220,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.crawl(id) engine.crawl(id)
} }
}.onFailure(::reportError) }.onFailure(::reportError)
reload() reloadNow()
matchAfterCrawl()
} }
} }
@@ -354,7 +480,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
syncError = null, syncError = null,
) )
} }
if (report.applied > 0u) reload() takeSynced(report)
true true
}, },
onFailure = { error -> onFailure = { error ->
@@ -368,6 +494,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. */ /** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() { fun playerClosed() {
reload() reload()
@@ -376,16 +529,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun addAccountFolder(name: String, volumeId: String, linkId: String) { fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch { viewModelScope.launch {
mutableState.update { it.copy(refreshing = true) }
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
val engine = NativeRuntime.engine() }.onSuccess { share ->
val share = engine.addAccountFolder(name, volumeId, linkId) reload()
engine.crawl(share.id) crawlAdded(listOf(share.id), announce = true)
}
}.onFailure(::reportError) }.onFailure(::reportError)
mutableState.update { it.copy(refreshing = false) }
reload()
} }
} }
@@ -487,22 +636,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
mutableState.update { it.copy(message = error.message ?: "Unexpected error") } mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
} }
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) { /**
viewModelScope.launch { * Store the enrichment settings, and return why not, if they were not.
runCatching { *
withContext(Dispatchers.IO) { * The page shows the new settings as soon as they are stored; the match
val engine = NativeRuntime.engine() * run that follows can take minutes and says so in the activity banner.
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) { */
engine.setMetadataApiKey(provider, apiKey) suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
} val stored = runCatching {
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) withContext(Dispatchers.IO) {
if (enabled) engine.matchTitles(false) else null val engine = NativeRuntime.engine()
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
engine.setMetadataApiKey(provider, apiKey)
} }
}.onSuccess { summary -> engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } } engine.metadataSettings()
}.onFailure(::reportError) }
reload() }.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() fun reloadAfterMetadataChange() = reload()
@@ -629,40 +783,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
private fun reload() { private fun reload() {
viewModelScope.launch { viewModelScope.launch { reloadNow() }
runCatching { }
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() /** [reload], for a caller that goes on to read what it loaded. */
// Here rather than inside `library()`, which is read-only private suspend fun reloadNow() {
// and cached: a full reload is the one place that has runCatching {
// already paid for a walk of the offline files. withContext(Dispatchers.IO) {
engine.pruneOfflineFiles() val engine = NativeRuntime.engine()
val current = mutableState.value // Here rather than inside `library()`, which is read-only
val query = current.query.takeIf(String::isNotBlank) // and cached: a full reload is the one place that has
Reloaded( // already paid for a walk of the offline files.
engine.shares(), engine.pruneOfflineFiles()
engine.library(query), val current = mutableState.value
engine.arrangement(query, current.sort, current.grouped), val query = current.query.takeIf(String::isNotBlank)
engine.offlineFiles(), Reloaded(
engine.metadataSettings(), engine.shares(),
engine.storageUsage(), engine.library(query),
) engine.arrangement(query, current.sort, current.grouped),
} engine.offlineFiles(),
}.onSuccess { result -> engine.metadataSettings(),
mutableState.update { it.copy( engine.storageUsage(),
loading = false, )
shares = result.shares, }
titles = result.titles, }.onSuccess { result ->
arrangement = result.arrangement, mutableState.update { it.copy(
offline = result.offline, loading = false,
metadataSettings = result.metadataSettings, shares = result.shares,
storage = result.storage, titles = result.titles,
) } arrangement = result.arrangement,
} offline = result.offline,
.onFailure { error -> metadataSettings = result.metadataSettings,
mutableState.update { it.copy(loading = false, message = error.message) } storage = result.storage,
} ) }
} }
.onFailure { error ->
mutableState.update { it.copy(loading = false, message = error.message) }
}
} }
private suspend fun reloadLibrary(query: String) { private suspend fun reloadLibrary(query: String) {
@@ -1,5 +1,16 @@
package io.narl.protonstream.ui 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.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -98,7 +109,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
@Composable @Composable
fun ProtonStreamApp( fun ProtonStreamApp(
playerHost: LibmpvHost? = null, playerHost: LibmpvHost? = null,
inPictureInPicture: Boolean = false,
shareLink: String? = null, shareLink: String? = null,
onShareLinkTaken: () -> Unit = {}, onShareLinkTaken: () -> Unit = {},
model: AppViewModel = viewModel( model: AppViewModel = viewModel(
@@ -327,22 +337,27 @@ fun ProtonStreamApp(
.padding(bottom = padding.calculateBottomPadding() + 8.dp), .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 // 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 // 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 // is what lets it draw into the cutout — but the page behind it stays
// Picture-in-Picture — but the page behind it stays composed, so leaving the // composed, so leaving the player finds the title page where it was rather
// player finds the title page where it was rather than rebuilt from the top. // than rebuilt from the top.
playing?.let { request -> playing?.let { request ->
PlayerScreen( PlayerScreen(
title = request.title, title = request.title,
episodes = request.episodes, episodes = request.episodes,
index = request.index, index = request.index,
host = playerHost, host = playerHost,
inPictureInPicture = inPictureInPicture,
minimized = playerMinimized, minimized = playerMinimized,
onIndexChange = { playingIndex = it }, onIndexChange = { playingIndex = it },
onSaveProgress = model::saveProgress, 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 @Composable
internal fun SettingsScreen( internal fun SettingsScreen(
state: AppUiState, 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, onMatchAgain: () -> Unit,
onClearCache: () -> Unit, onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit, onRemoveAllOffline: () -> Unit,
@@ -327,10 +328,7 @@ internal fun SettingsScreen(
MetadataSettingsDialog( MetadataSettingsDialog(
current = state.metadataSettings, current = state.metadataSettings,
onDismiss = { showMetadata = false }, onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key -> onSave = onSaveMetadata,
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
) )
} }
} }
@@ -411,14 +409,18 @@ private fun SettingAction(
private fun MetadataSettingsDialog( private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord, current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit, onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
) { ) {
var enabled by remember { mutableStateOf(current.enabled) } var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) } var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) } var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") } 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") }, title = { Text("Metadata enrichment") },
text = { text = {
@@ -447,15 +449,25 @@ private fun MetadataSettingsDialog(
singleLine = true, 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 = { confirmButton = {
AccentButton( AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(), enabled = !needsKey && !saving,
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) }, onClick = {
) { Text("Save") } 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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -50,7 +55,8 @@ import androidx.compose.ui.graphics.Color
@Composable @Composable
internal fun SharesScreen( internal fun SharesScreen(
shares: List<ShareRecord>, 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, onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit, onRefresh: (String) -> Unit,
onRemove: (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 @Composable
private fun AddShareDialog( private fun AddShareDialog(
initialUrl: String, initialUrl: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (String, String, String?) -> Unit, onAdd: suspend (String, String, String?) -> String?,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var url by remember(initialUrl) { mutableStateOf(initialUrl) } var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var adding by remember { mutableStateOf(false) }
var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = { if (!adding) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") }, title = { Text("Add Proton Drive share") },
text = { text = {
@@ -312,14 +326,30 @@ private fun AddShareDialog(
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 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 = { confirmButton = {
AccentButton( AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() }, onClick = {
enabled = name.isNotBlank() && url.isNotBlank(), adding = true
) { Text("Add") } 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 } .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. */ /** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) { fun apply(palette: PaletteRecord) {
current.value = palette current.value = palette
@@ -9,7 +9,7 @@ class AccountUiTest {
@Test @Test
fun `the sync line says how long ago and what came in`() { fun `the sync line says how long ago and what came in`() {
val minute = 60_000L 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 just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute)) assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals( assertEquals(
@@ -21,7 +21,7 @@ class AccountUiTest {
@Test @Test
fun `a failed sync is said in place of when it last worked`() { fun `a failed sync is said in place of when it last worked`() {
assertEquals( assertEquals(
"Watch history did not sync: offline", "Did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0), syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
) )
} }
@@ -137,22 +137,22 @@ class ScreenshotTest {
@Test @Test
fun `the shares page`() = capture("shares-phone") { fun `the shares page`() = capture("shares-phone") {
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues()) SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
} }
@Test @Test
fun `the settings page`() = capture("settings-phone") { fun `the settings page`() = capture("settings-phone") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0") SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
} }
@Test @Test
fun `the playback settings`() = capture("settings-playback") { fun `the playback settings`() = capture("settings-playback") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
} }
@Test @Test
fun `the storage settings`() = capture("settings-storage") { fun `the storage settings`() = capture("settings-storage") {
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
} }
@Test @Test
+151 -41
View File
@@ -187,6 +187,16 @@ pub struct SyncRecord {
pub uploaded: bool, pub uploaded: bool,
/// Other installations whose history was read. /// Other installations whose history was read.
pub devices: u32, pub devices: u32,
/// Shares another device added or changed, configured here and not yet
/// crawled. Crawl each.
pub shares_added: Vec<String>,
/// Shares another device removed, gone here with their offline files.
/// Drop any download state kept for them.
pub shares_removed: Vec<String>,
/// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
@@ -704,6 +714,22 @@ impl<T> Drop for AbortOnDrop<T> {
} }
} }
/// 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 /// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the
/// caller stops waiting. Awaits to exactly what `JoinHandle` does. /// caller stops waiting. Awaits to exactly what `JoinHandle` does.
fn spawned<T: Send + 'static>( fn spawned<T: Send + 'static>(
@@ -1143,12 +1169,31 @@ impl AndroidEngine {
else { else {
return Ok(None); return Ok(None);
}; };
match sync.sync(&account, &self.catalog).await { match sync.sync(&account, &self.catalog, &self.store).await {
Ok(report) => Ok(Some(SyncRecord { Ok(report) => {
applied: u32::try_from(report.applied).unwrap_or(u32::MAX), let profile = report.profile;
uploaded: report.uploaded, for id in profile.shares_added.iter().chain(&profile.shares_changed) {
devices: u32::try_from(report.devices).unwrap_or(u32::MAX), 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() => { Err(error) if error.is_signed_out() => {
let _ = self.accounts.sign_out(None).await; let _ = self.accounts.sign_out(None).await;
self.signed_out(); self.signed_out();
@@ -1223,6 +1268,26 @@ impl AndroidEngine {
Ok(share_record(share)) 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<Vec<String>, 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<Vec<ShareRecord>, BridgeError> { pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
self.store self.store
.list() .list()
@@ -1230,18 +1295,34 @@ impl AndroidEngine {
.map(|shares| shares.into_iter().map(share_record).collect()) .map(|shares| shares.into_iter().map(share_record).collect())
} }
pub fn add_share( /// Add a public link, once it has opened with the password given. A link
&self, /// that will not open is refused and leaves nothing behind. Crawl the
/// returned share next.
pub async fn add_share(
self: Arc<Self>,
name: String, name: String,
url: String, url: String,
custom_password: Option<String>, custom_password: Option<String>,
) -> Result<ShareRecord, BridgeError> { ) -> Result<ShareRecord, BridgeError> {
let share = self let runtime = Arc::clone(&self.runtime);
.store spawned(&runtime, async move {
.add(&name, &url, custom_password.as_deref()) let (share, client) = self
.map_err(BridgeError::from_display)?; .store
self.invalidate_connection(&share.id); .add_verified(&name, &url, custom_password.as_deref())
Ok(share_record(share)) .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. /// 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 // when secret cleanup fails, old authenticated clients and catalog
// rows must not remain usable. // rows must not remain usable.
self.invalidate_connection(&share_id); self.invalidate_connection(&share_id);
self.forget_share_files(&share_id, offline, links)?;
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)?;
store_result.map_err(BridgeError::from_display) store_result.map_err(BridgeError::from_display)
} }
@@ -1901,6 +1961,56 @@ impl AndroidEngine {
} }
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<String>,
) -> 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<String> = 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. /// One enrichment pass over the whole library.
/// ///
/// What to do per title is decided by `pstr_meta::service::plan`, the same /// What to do per title is decided by `pstr_meta::service::plan`, the same
@@ -3765,13 +3875,13 @@ mod tests {
) )
.expect("engine"); .expect("engine");
// Stored directly: `add_share` opens the link first, and a test has
// no network to open it over.
let share = engine let share = engine
.add_share( .store
"test".to_owned(), .add("test", "https://drive.proton.me/urls/ABC123#s3cr3t", None)
"https://drive.proton.me/urls/ABC123#s3cr3t".to_owned(),
None,
)
.expect("add share"); .expect("add share");
engine.invalidate_connection(&share.id);
assert_eq!(engine.share_generation.load(Ordering::Acquire), 1); assert_eq!(engine.share_generation.load(Ordering::Acquire), 1);
assert!( assert!(
engine engine
+6
View File
@@ -625,6 +625,7 @@ impl App {
// that was crawled yesterday is on screen before the shares open. // that was crawled yesterday is on screen before the shares open.
engine.load_shares(); engine.load_shares();
engine.load_library(); engine.load_library();
engine.forget_orphaned_shares();
engine.load_metadata(); engine.load_metadata();
engine.connect(); engine.connect();
let settings = engine.metadata_config(); let settings = engine.metadata_config();
@@ -793,6 +794,10 @@ impl App {
self.view.stale = true; self.view.stale = true;
} }
Event::EpisodeMetadata(episodes) => self.episodes = episodes, Event::EpisodeMetadata(episodes) => self.episodes = episodes,
Event::Appearance(appearance) => {
theme::apply(ctx, appearance);
self.view.stale = true;
}
Event::MetadataConfig(config) => { Event::MetadataConfig(config) => {
self.settings = config; self.settings = config;
self.posters.clear(); self.posters.clear();
@@ -815,6 +820,7 @@ impl App {
} }
self.note(ctx, text, failed > 0); self.note(ctx, text, failed > 0);
} }
Event::MatchStarted => self.matching = true,
Event::MatchFinished => self.matching = false, Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may // Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was // have closed it or opened another one while the request was
+152 -13
View File
@@ -34,6 +34,7 @@ use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
@@ -215,12 +216,16 @@ pub enum Event {
EpisodeMetadata(HashMap<String, EpisodeGuide>), EpisodeMetadata(HashMap<String, EpisodeGuide>),
/// The enrichment settings changed, on disk. /// The enrichment settings changed, on disk.
MetadataConfig(MetadataConfig), MetadataConfig(MetadataConfig),
/// Another device's appearance arrived through sync.
Appearance(Appearance),
/// A matching run finished, with how it went. /// A matching run finished, with how it went.
Matched { Matched {
matched: usize, matched: usize,
unmatched: usize, unmatched: usize,
failed: usize, failed: usize,
}, },
/// A matching run has work to do — one asked for, or one after a crawl.
MatchStarted,
/// Every requested match is done. /// Every requested match is done.
MatchFinished, MatchFinished,
/// What the provider answered a hand search with, unscored and in its own /// What the provider answered a hand search with, unscored and in its own
@@ -279,7 +284,7 @@ pub enum Event {
SignInFailed(String), SignInFailed(String),
/// Watch history synced with the account's Drive. /// Watch history synced with the account's Drive.
Synced(SyncReport), 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. /// timer, and an offline evening would be a stack of them.
SyncFailed(String), SyncFailed(String),
/// The account's top-level places, for the Drive browser. /// The account's top-level places, for the Drive browser.
@@ -640,12 +645,17 @@ impl Engine {
} }
/// Add a share, then open and crawl it — which is what "add" means to /// 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<String>) { pub fn add_share(&self, name: String, url: String, password: Option<String>) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
let share = match engine.store.add(&name, &url, password.as_deref()) { let share = match engine
Ok(share) => share, .store
.add_verified(&name, &url, password.as_deref())
.await
{
Ok((share, _)) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
@@ -836,9 +846,10 @@ impl Engine {
}; };
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
match sync.sync(&account, &engine.catalog).await { match sync.sync(&account, &engine.catalog, &engine.store).await {
Ok(report) => { 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.load_library();
} }
engine.emit(Event::Synced(report)); engine.emit(Event::Synced(report));
@@ -875,8 +886,9 @@ impl Engine {
return; return;
}; };
let catalog = Arc::clone(&self.catalog); let catalog = Arc::clone(&self.catalog);
let store = Arc::clone(&self.store);
self.runtime.block_on(async move { 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(Ok(_)) => {}
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"), Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
Err(_) => tracing::warn!("sync watch history on exit: timed out"), Err(_) => tracing::warn!("sync watch history on exit: timed out"),
@@ -884,6 +896,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) { fn sync_periodically(&self) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
@@ -929,6 +993,48 @@ impl Engine {
pub fn remove_share(&self, id: String) { pub fn remove_share(&self, id: String) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.forget_share(id, true).await;
engine.connection.lock().take();
engine.load_library();
engine.emit(Event::Status("removed".into()));
});
}
/// Forget every share the catalog holds files for that the share list no
/// longer names — what an older `pstr remove` left behind. Its files would
/// otherwise sit in the library beside whatever replaced it, every episode
/// twice.
pub fn forget_orphaned_shares(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let configured = match engine.store.list() {
Ok(shares) => shares,
Err(error) => return engine.fail("read the share list", error),
};
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
Ok(ids) => ids
.into_iter()
.filter(|id| !configured.iter().any(|share| &share.id == id))
.collect(),
Err(error) => return engine.fail("read the catalog", error),
};
if orphans.is_empty() {
return;
}
for id in orphans {
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
engine.forget_share(id, false).await;
}
engine.load_library();
});
}
/// 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 let keys: Vec<_> = engine
.downloads .downloads
.lock() .lock()
@@ -971,7 +1077,7 @@ impl Engine {
if let Err(error) = engine.catalog.lock().remove_share(&id) { if let Err(error) = engine.catalog.lock().remove_share(&id) {
return engine.fail("drop that share's catalog rows", error); 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); return engine.fail("remove that share", error);
} }
{ {
@@ -983,9 +1089,7 @@ impl Engine {
engine.load_shares(); engine.load_shares();
// Its clients are stale now; the next action reopens what is left. // Its clients are stale now; the next action reopens what is left.
engine.connection.lock().take(); engine.connection.lock().take();
engine.load_library(); }
engine.emit(Event::Status("removed".into()));
});
} }
// ------------------------------------------------------------ connection // ------------------------------------------------------------ connection
@@ -1198,6 +1302,32 @@ impl Engine {
) )
.await; .await;
self.emit(Event::CrawlFinished); self.emit(Event::CrawlFinished);
self.match_after_crawl().await;
}
/// Look up whatever the crawl brought that has no answer yet — a new show,
/// or a new episode the stored guide cannot name. Silent when there is
/// nothing to ask, which is almost every time: `plan` makes no requests for
/// a library that is already matched.
async fn match_after_crawl(&self) {
if !self.metadata_config().enabled {
return;
}
let titles = {
let catalog = self.catalog.lock();
match catalog
.all_files()
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
{
Ok(library) => library.titles,
Err(error) => return self.fail("read the catalog", error),
}
};
if titles.is_empty() {
return;
}
self.run_match(titles, false, true).await;
self.emit(Event::MatchFinished);
} }
/// Stop every crawl still listing. Shares already past their listing /// Stop every crawl still listing. Shares already past their listing
@@ -2233,13 +2363,18 @@ impl Engine {
pub fn match_titles(&self, titles: Vec<Title>, force: bool) { pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
engine.run_match(titles, force).await; engine.run_match(titles, force, false).await;
engine.emit(Event::MatchFinished); engine.emit(Event::MatchFinished);
}); });
} }
async fn run_match(&self, titles: Vec<Title>, force: bool) { /// `automatic` is a run nobody asked for, after a crawl: it says nothing
/// when there is nothing to do, or no provider to do it with.
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
let Some(service) = self.metadata_service().await else { let Some(service) = self.metadata_service().await else {
if automatic {
return;
}
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider(); let provider = service.provider();
@@ -2264,8 +2399,12 @@ impl Engine {
force, force,
); );
if pending.is_empty() { if pending.is_empty() {
if automatic {
return;
}
return self.emit(Event::Status("everything is already matched".into())); return self.emit(Event::Status("everything is already matched".into()));
} }
self.emit(Event::MatchStarted);
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
+8 -5
View File
@@ -133,9 +133,10 @@ impl DriveBrowser {
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) { pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account"); ui::section(ui, "Proton account");
ui.label(ui::muted( ui.label(ui::muted(
"Optional. Signed in, your watch history is kept in your own Drive and every device \ "Optional. Signed in, your shares, settings and watch history are kept in your own \
resumes where another left off, and folders of your Drive — your files, other \ Drive, so every device shows the same library and resumes where another left off, \
devices, what was shared with you — can be added to the library.", 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); ui.add_space(theme::space::L);
@@ -281,9 +282,11 @@ fn signed_in(
.strong(), .strong(),
); );
let status = match (&panel.sync_error, &panel.synced) { 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, 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)); ui.label(ui::muted(status));
}); });
+41 -4
View File
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
None None
}; };
let share = store let (share, _) = store
.add(&name, &url, password.as_deref()) .add_verified(&name, &url, password.as_deref())
.await
.context("add share")?; .context("add share")?;
println!("added {} ({})", share.name, share.id); println!("added {} ({})", share.name, share.id);
} }
@@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let sync = WatchSync::open(&dirs).context("open sync state")?; let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog = let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open 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!( 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.devices,
report.applied, report.applied,
profile.shares_added.len() + profile.shares_changed.len(),
profile.shares_removed.len(),
if profile.settings {
"updated"
} else {
"unchanged"
},
if report.uploaded { if report.uploaded {
"uploaded this device's history" "uploaded this device's history"
} else { } else {
@@ -358,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
Command::Remove { id } => { Command::Remove { id } => {
store.remove(&id).context("remove share")?; store.remove(&id).context("remove share")?;
// Its catalog rows go too, or the library keeps listing its files —
// beside the new share's, when the same folder was added back
// another way.
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let offline = catalog
.offline_files_for_share(&id)
.context("read the share's downloads")?;
for (link_id, file) in &offline {
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(error).context("delete a downloaded file");
}
_ => {}
}
}
catalog
.remove_share(&id)
.context("drop the share's catalog rows")?;
println!("removed {id}"); println!("removed {id}");
} }
+278 -5
View File
@@ -254,6 +254,16 @@ fn title_track_prefs_row(
/// What `season = -1` means in `episode_metadata`: no season at all. /// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1; const ABSOLUTE_SEASON: i64 = -1;
/// How long after an episode airs its guide keeps being asked about daily
/// while the provider has no name for it.
///
/// A guide is otherwise trusted as long as a match, a month, and one fetched
/// the week before an episode aired lists it unnamed — "Episode 2" with a
/// date — until the month is up. A name usually follows the air date by a day
/// or so; one that has not come in two weeks is not coming, and asking every
/// day for the rest of time would not change that.
const GUIDE_WAIT_DAYS: i64 = 14;
/// One catalog row: a file or folder inside a share. /// One catalog row: a file or folder inside a share.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogNode { pub struct CatalogNode {
@@ -513,6 +523,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?) Ok(rows.collect::<std::result::Result<_, _>>()?)
} }
/// Every share the catalog holds files for. One no longer configured was
/// forgotten without [`Catalog::remove_share`] — by an older `pstr remove`
/// — and its rows still show up in the library beside whatever replaced it.
pub fn share_ids(&self) -> Result<Vec<String>> {
let mut statement = self
.conn
.prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?;
let rows = statement.query_map([], |row| row.get(0))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed except where it was watched. /// Drop everything a share contributed except where it was watched.
/// ///
/// Watch state stays, as through a recrawl. It is history, and not only /// Watch state stays, as through a recrawl. It is history, and not only
@@ -521,8 +542,34 @@ impl Catalog {
/// only make it flicker. Nothing shows it — the library pairs positions /// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again, /// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off. /// when it picks up where it left off.
///
/// It is also copied, newer wins as in a merge, onto every other share that
/// holds the same file. A public link swapped for the account's own folder
/// reaches the very same links under a new share id, and without the copy
/// the swap reads as a library nobody has watched.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> { pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?; let tx = self.conn.transaction()?;
tx.execute(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at
FROM watch_state w
JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id
JOIN nodes other ON other.volume_id = own.volume_id
AND other.link_id = own.link_id
AND other.share_id <> own.share_id
WHERE w.share_id = ?1
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
params![share_id],
)?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
@@ -1030,14 +1077,27 @@ impl Catalog {
} }
/// When each matched title was last enriched, for the provider it is /// When each matched title was last enriched, for the provider it is
/// matched against. A title missing here has never been, or its match has /// matched against. A title missing here has never been, its match has
/// changed since. /// changed since, or its guide is waiting on the name of an episode that
/// has aired since — see `GUIDE_WAIT_DAYS`.
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> { pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
self.enrichment_ages_at(unix_now())
}
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, provider, enriched_at FROM title_metadata "SELECT title_key, provider, enriched_at FROM title_metadata t
WHERE enriched_at IS NOT NULL", WHERE enriched_at IS NOT NULL
AND NOT (enriched_at < ?1 - 86400
AND EXISTS (
SELECT 1 FROM episode_metadata e
WHERE e.title_key = t.title_key
AND e.name IS NULL
AND e.air_date <= date(?1, 'unixepoch')
AND e.air_date >= date(t.enriched_at, 'unixepoch',
'-' || ?2 || ' days')))",
)?; )?;
let rows = statement.query_map([], |row| { let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| {
Ok(( Ok((
row.get::<_, String>(0)?, row.get::<_, String>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
@@ -1052,6 +1112,36 @@ impl Catalog {
ages.insert(title_key, (provider, enriched_at)); ages.insert(title_key, (provider, enriched_at));
} }
} }
drop(statement);
// A file that arrived after the guide was fetched, and that the guide
// has no name for, is an episode the guide predates: the next one of a
// running show. Asked again once — the refetch is newer than the file,
// so a name the provider never gives does not make it ask forever.
if !ages.is_empty() {
let first_seen = self.first_seen()?;
let guides = self.all_episode_metadata()?;
for node in self.all_files()? {
let Some(number) = node.parsed.episode else {
continue;
};
let key = crate::library::title_key(&node.parsed.title);
let Some((_, enriched_at)) = ages.get(&key) else {
continue;
};
let seen = first_seen
.get(&(node.share_id, node.link_id))
.copied()
.unwrap_or(0);
let named = guides
.get(&key)
.and_then(|guide| guide.get(node.parsed.season, number))
.is_some_and(|episode| episode.name.is_some());
if seen > *enriched_at && !named {
ages.remove(&key);
}
}
}
Ok(ages) Ok(ages)
} }
} }
@@ -1844,6 +1934,129 @@ mod tests {
assert!(catalog.enrichment_ages().expect("ages").is_empty()); assert!(catalog.enrichment_ages().expect("ages").is_empty());
} }
#[test]
fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_000,
manual: false,
})
.expect("store");
let enrichment = Enrichment {
episodes: vec![EpisodeMetadata {
season: Some(3),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Locusts".into()),
overview: None,
still_url: None,
air_date: None,
}],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog| {
catalog
.enrichment_ages()
.expect("ages")
.contains_key("kusuriya")
};
// Newer than the guide, but the guide names it.
let first = file("s", "e1", "Kusuriya S03E01.mkv");
catalog
.replace_share("s", std::slice::from_ref(&first))
.expect("crawl");
assert!(trusted(&catalog));
// The next episode, which the guide does not know yet.
catalog
.replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")])
.expect("crawl again");
assert!(!trusted(&catalog));
// Fetched again since, still without it: not asked about again.
catalog
.set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog));
}
#[test]
fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() {
const DAY: i64 = 86_400;
// 2026-10-04, when the guide was fetched; episode 2 airs on the 9th.
let fetched = 1_791_072_000;
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "kusuriya".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: fetched,
manual: false,
})
.expect("store");
let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata {
season: Some(3),
number,
absolute: None,
entry_season: Some(1),
entry_number: Some(number),
name: name.map(str::to_owned),
overview: None,
still_url: None,
air_date: Some(air_date.into()),
};
let enrichment = Enrichment {
episodes: vec![
episode(1, Some("Locusts"), "2026-10-02"),
episode(2, None, "2026-10-09"),
],
backdrop_url: None,
chain: Vec::new(),
};
catalog
.set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment)
.expect("enrich");
let trusted = |catalog: &Catalog, now: i64| {
catalog
.enrichment_ages_at(now)
.expect("ages")
.contains_key("kusuriya")
};
// Before episode 2 airs, a nameless future episode is no reason.
assert!(trusted(&catalog, fetched + 4 * DAY));
// Once it has aired, the guide is asked again.
assert!(!trusted(&catalog, fetched + 8 * DAY));
// Asked on the 12th and still nameless: not again the same day…
let refetched = fetched + 8 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment)
.expect("enrich again");
assert!(trusted(&catalog, refetched + DAY / 2));
// …but the next.
assert!(!trusted(&catalog, refetched + DAY + 1));
// Two weeks past the air date it is given up on.
let given_up = fetched + 21 * DAY;
catalog
.set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment)
.expect("enrich late");
assert!(trusted(&catalog, given_up + 2 * DAY));
}
/// Details round-trip, and refreshing a match's details — the same entry /// Details round-trip, and refreshing a match's details — the same entry
/// stored again — keeps what enrichment found for it. /// stored again — keeps what enrichment found for it.
#[test] #[test]
@@ -2460,6 +2673,66 @@ mod tests {
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state)); assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
} }
#[test]
fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.replace_share(
"link",
&[
file("link", "a", "Show S01E01.mkv"),
file("link", "b", "Show S01E02.mkv"),
file("link", "gone", "Show S01E03.mkv"),
],
)
.expect("crawl the link");
catalog
.replace_share(
"folder",
&[
file("folder", "a", "Show S01E01.mkv"),
file("folder", "b", "Show S01E02.mkv"),
],
)
.expect("crawl the folder");
catalog
.set_watch_state("link", "a", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "b", &at(100.0, 10))
.unwrap();
catalog
.set_watch_state("link", "gone", &at(100.0, 10))
.unwrap();
// Watched through the folder since: the link's older position must
// not overwrite it.
catalog
.set_watch_state("folder", "b", &at(900.0, 20))
.unwrap();
catalog.remove_share("link").expect("remove");
assert_eq!(
catalog.watch_state("folder", "a").unwrap(),
Some(at(100.0, 10))
);
assert_eq!(
catalog.watch_state("folder", "b").unwrap(),
Some(at(900.0, 20))
);
assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None);
assert_eq!(
catalog.watch_state("link", "a").unwrap(),
Some(at(100.0, 10))
);
}
#[test] #[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() { fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open"); let mut catalog = Catalog::in_memory().expect("open");
+1
View File
@@ -17,6 +17,7 @@ pub mod library;
pub mod metadata; pub mod metadata;
pub mod naming; pub mod naming;
pub mod prefs; pub mod prefs;
pub mod profile;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod sync; pub mod sync;
+808
View File
@@ -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/<id>`,
//! `settings/playback`, `tracks/<title>`, …), 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"));
}
}
+68 -20
View File
@@ -355,19 +355,19 @@ impl AccountFolder {
} }
/// The secrets for one share, as held in the credential store. /// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ShareSecrets { pub(crate) struct ShareSecrets {
/// The full share URL, fragment included. /// The full share URL, fragment included.
url: String, pub(crate) url: String,
/// The custom password, when the link has one. /// The custom password, when the link has one.
#[serde(default)] #[serde(default)]
custom_password: Option<String>, pub(crate) custom_password: Option<String>,
} }
/// The configured shares, and their secrets. /// The configured shares, and their secrets.
pub struct ShareStore { pub struct ShareStore {
dirs: AppDirs, pub(crate) dirs: AppDirs,
secrets: Arc<dyn SecretStore>, pub(crate) secrets: Arc<dyn SecretStore>,
} }
impl ShareStore { impl ShareStore {
@@ -396,9 +396,9 @@ impl ShareStore {
let mut shares = self.list()?; let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) { if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!( return Err(Error::Config(
"a share for token {token} is already configured" "that link is already in the library".to_owned(),
))); ));
} }
let secrets = ShareSecrets { let secrets = ShareSecrets {
@@ -421,6 +421,53 @@ impl ShareStore {
Ok(share) 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. /// Record a folder of the signed-in account as a share.
/// ///
/// Nothing goes in the credential store: the folder opens through the /// 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 secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open( open_link(&secrets.url, secrets.custom_password.as_deref()).await
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)))
} }
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { 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. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ShareClient>, clients: BTreeMap<String, ShareClient>,
+151 -74
View File
@@ -1,5 +1,7 @@
//! Watch history kept in the viewer's own Drive, so every device resumes where //! 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 //! ## Layout
//! //!
@@ -17,7 +19,8 @@
//! nothing is overwritten and there is no write conflict to resolve. //! nothing is overwritten and there is no write conflict to resolve.
//! //!
//! Each file carries the installation's whole watch table, and merging is per //! 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 //! 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 //! 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 //! 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 //! 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 //! `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 //! the same `(share_id, link_id)` on both. The watch entries carry nothing
//! link token decrypts nothing without the fragment — and the file is //! secret; the profile's share registers do carry each link and its password,
//! end-to-end encrypted by Drive regardless. //! 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 parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient}; use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
@@ -40,6 +43,8 @@ use crate::account::Account;
use crate::catalog::{Catalog, WatchState}; use crate::catalog::{Catalog, WatchState};
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::profile::{Profile, ProfileChanges, Registers};
use crate::shares::ShareStore;
/// The Drive device watch history is kept on. /// The Drive device watch history is kept on.
pub const DEVICE_NAME: &str = "proton-stream"; pub const DEVICE_NAME: &str = "proton-stream";
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
const FOLDER: &str = "watch-history"; const FOLDER: &str = "watch-history";
/// Bumped only for a change an older build would misread. A file of a newer /// 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; const FORMAT_VERSION: u32 = 1;
/// One installation's watch history, as stored in Drive. /// One installation's watch history, as stored in Drive.
@@ -57,6 +63,15 @@ struct HistoryFile {
version: u32, version: u32,
installation: String, installation: String,
entries: Vec<HistoryEntry>, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -112,7 +127,7 @@ struct Identity {
} }
/// What one [`WatchSync::sync`] did. /// What one [`WatchSync::sync`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncReport { pub struct SyncReport {
/// Episodes whose position another device had newer. /// Episodes whose position another device had newer.
pub applied: usize, pub applied: usize,
@@ -123,6 +138,8 @@ pub struct SyncReport {
/// Files of installations gone quiet that were moved to the trash, their /// Files of installations gone quiet that were moved to the trash, their
/// history now carried in this installation's own file. /// history now carried in this installation's own file.
pub retired: usize, 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 /// 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` /// 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 { struct Seen {
revision: Option<String>, revision: Option<String>,
entries: Option<Vec<HistoryEntry>>, contents: Option<Contents>,
} }
/// Syncs the catalog's watch table with the account's Drive. /// Syncs the catalog's watch table with the account's Drive.
@@ -257,8 +274,9 @@ pub struct WatchSync {
seen: Mutex<HashMap<String, Seen>>, seen: Mutex<HashMap<String, Seen>>,
/// What this installation's own file holds, once known: what was read /// What this installation's own file holds, once known: what was read
/// back at the first sync, then what was last written. Compared against /// 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. /// the catalog and the profile so an unchanged history is not uploaded as
own: Mutex<Option<Vec<HistoryEntry>>>, /// a new revision.
own: Mutex<Option<Contents>>,
/// The history folders, resolved once per account. Usually one; see /// The history folders, resolved once per account. Usually one; see
/// [`HistoryDrive::history_folders`]. /// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>, folders: Mutex<Option<Vec<NodeUid>>>,
@@ -303,25 +321,34 @@ impl WatchSync {
*self.folders.lock() = None; *self.folders.lock() = None;
} }
/// Pull every other installation's history into `catalog`, write this /// Pull every other installation's history, shares and settings in, write
/// one's if it changed, then retire the files of installations gone quiet. /// 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 /// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this /// across a request, so playback can keep saving its position while this
/// runs. /// runs. Shares that arrive are configured but not opened or crawled; see
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> { /// [`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() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64); .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>( pub(crate) async fn sync_with<D: HistoryDrive>(
&self, &self,
drive: &D, drive: &D,
catalog: &Mutex<Catalog>, profile: &Profile<'_>,
now: i64, now: i64,
) -> Result<SyncReport> { ) -> Result<SyncReport> {
let catalog = profile.catalog;
let _running = self.running.lock().await; let _running = self.running.lock().await;
let folders = self.folders(drive).await?; let folders = self.folders(drive).await?;
let own_name = format!("{}.json", self.installation); let own_name = format!("{}.json", self.installation);
@@ -339,12 +366,14 @@ impl WatchSync {
} }
let mut remote = Vec::new(); let mut remote = Vec::new();
let mut registers = Vec::new();
// Files whose every entry has been read, and so can be retired once // Files whose every entry has been read, and so can be retired once
// this installation's file carries them. // this installation's file carries them.
let mut readable = Vec::new(); let mut readable = Vec::new();
for file in &others { for file in &others {
if let Some(entries) = self.read(drive, file).await { if let Some(contents) = self.read(drive, file).await {
remote.extend(entries); remote.extend(contents.entries);
registers.push(contents.registers);
readable.push(file); readable.push(file);
} }
} }
@@ -355,19 +384,22 @@ impl WatchSync {
// less than it holds, and read again next time. // less than it holds, and read again next time.
let mut own_unreadable = false; let mut own_unreadable = false;
if self.own.lock().is_none() { if self.own.lock().is_none() {
let entries = match &own_file { let contents = match &own_file {
Some(file) => self.read(drive, file).await, Some(file) => self.read(drive, file).await,
None => Some(Vec::new()), None => Some(Contents::default()),
}; };
match entries { match contents {
Some(entries) => { Some(contents) => {
remote.extend(entries.iter().cloned()); remote.extend(contents.entries.iter().cloned());
*self.own.lock() = Some(entries); registers.push(contents.registers.clone());
*self.own.lock() = Some(contents);
} }
None => own_unreadable = true, None => own_unreadable = true,
} }
} }
let (registers, profile_changes) = profile.sync(&registers, now)?;
let incoming: Vec<(String, String, WatchState)> = remote let incoming: Vec<(String, String, WatchState)> = remote
.iter() .iter()
.filter(|entry| entry.is_sane()) .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 // 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 // not changed: another installation retired it, or it was deleted by
// hand, and either way the history it carried lives only here now. // 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 contents = Contents { entries, registers };
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none()) 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 false
} else { } else {
let file = HistoryFile { let file = HistoryFile {
version: FORMAT_VERSION, version: FORMAT_VERSION,
installation: self.installation.clone(), installation: self.installation.clone(),
entries: entries.clone(), entries: contents.entries.clone(),
registers: contents.registers.clone(),
}; };
let bytes = serde_json::to_vec(&file) let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?; .map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
@@ -404,7 +438,7 @@ impl WatchSync {
Some(file) => drive.replace(&file.uid, &bytes).await?, Some(file) => drive.replace(&file.uid, &bytes).await?,
None => drive.create(&folders[0], &own_name, &bytes).await?, None => drive.create(&folders[0], &own_name, &bytes).await?,
} }
*self.own.lock() = Some(entries); *self.own.lock() = Some(contents);
true true
}; };
@@ -435,34 +469,31 @@ impl WatchSync {
uploaded, uploaded,
devices: others.len(), devices: others.len(),
retired: retired.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. /// not moved, or `None` when it cannot be read.
/// ///
/// A file that will not download or parse is logged and skipped: it is /// 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. /// another device's, and one bad file must not stop the rest syncing.
async fn read<D: HistoryDrive>( async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
&self,
drive: &D,
file: &RemoteFile,
) -> Option<Vec<HistoryEntry>> {
let key = file.uid.link_id.as_str().to_owned(); let key = file.uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key) if let Some(seen) = self.seen.lock().get(&key)
&& file.revision.is_some() && file.revision.is_some()
&& seen.revision == file.revision && seen.revision == file.revision
{ {
return seen.entries.clone(); return seen.contents.clone();
} }
let uid = &file.uid; let uid = &file.uid;
let entries = match drive.download(uid).await { let contents = match drive.download(uid).await {
Ok(bytes) => { Ok(bytes) => {
let entries = parse(&bytes); let contents = parse(&bytes);
if entries.is_none() { if contents.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read"); tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
} }
entries contents
} }
Err(error) => { Err(error) => {
// Not cached: a download that failed is tried again next time. // Not cached: a download that failed is tried again next time.
@@ -474,10 +505,10 @@ impl WatchSync {
key, key,
Seen { Seen {
revision: file.revision.clone(), revision: file.revision.clone(),
entries: entries.clone(), contents: contents.clone(),
}, },
); );
entries contents
} }
/// The history folders, resolved once per account. /// 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. /// A history file's contents, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> { fn parse(bytes: &[u8]) -> Option<Contents> {
let file: HistoryFile = serde_json::from_slice(bytes).ok()?; 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. /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::profile::tests::Device;
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry { fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
HistoryEntry { HistoryEntry {
@@ -655,11 +690,8 @@ mod tests {
} }
/// An installation: its sync and its catalog. /// An installation: its sync and its catalog.
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) { fn device(id: &str) -> (WatchSync, Device) {
( (WatchSync::with_installation(id.to_owned()), Device::new(id))
WatchSync::with_installation(id.to_owned()),
Mutex::new(Catalog::in_memory().unwrap()),
)
} }
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState { fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
@@ -684,11 +716,14 @@ mod tests {
.unwrap(); .unwrap();
let first = laptop let first = laptop
.sync_with(&drive, &laptop_catalog, 100) .sync_with(&drive, &laptop_catalog.profile(), 100)
.await .await
.unwrap(); .unwrap();
assert!(first.uploaded); 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.applied, 1);
assert_eq!(second.devices, 1); assert_eq!(second.devices, 1);
@@ -714,12 +749,15 @@ mod tests {
.unwrap(); .unwrap();
laptop 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 .await
.unwrap(); .unwrap();
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
laptop laptop
.sync_with(&drive, &laptop_catalog, 300) .sync_with(&drive, &laptop_catalog.profile(), 300)
.await .await
.unwrap(); .unwrap();
@@ -740,8 +778,14 @@ mod tests {
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap(); .unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap(); laptop
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap(); .sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let again = laptop
.sync_with(&drive, &catalog.profile(), 200)
.await
.unwrap();
assert!(!again.uploaded); assert!(!again.uploaded);
assert_eq!(drive.uploads(), 1); assert_eq!(drive.uploads(), 1);
@@ -751,7 +795,10 @@ mod tests {
async fn nothing_is_uploaded_before_anything_was_watched() { async fn nothing_is_uploaded_before_anything_was_watched() {
let drive = FakeDrive::default(); let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop"); 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!(!report.uploaded);
assert!(drive.names().is_empty()); assert!(drive.names().is_empty());
} }
@@ -764,11 +811,17 @@ mod tests {
.lock() .lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap(); .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(); let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
drive.trash(&own).await.unwrap(); 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!(report.uploaded);
assert_eq!(drive.names(), ["laptop.json"]); assert_eq!(drive.names(), ["laptop.json"]);
@@ -782,11 +835,17 @@ mod tests {
.lock() .lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap(); .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. // The catalog was deleted; the config, and with it the id, was not.
let (relaunched, empty) = device("laptop"); 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_eq!(report.applied, 1);
assert!(!report.uploaded); assert!(!report.uploaded);
@@ -804,7 +863,10 @@ mod tests {
.lock() .lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100)) .set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap(); .unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap(); laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let (relaunched, other) = device("laptop"); let (relaunched, other) = device("laptop");
other other
@@ -814,13 +876,19 @@ mod tests {
drive drive
.offline .offline
.store(true, std::sync::atomic::Ordering::SeqCst); .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); assert!(!report.uploaded);
drive drive
.offline .offline
.store(false, std::sync::atomic::Ordering::SeqCst); .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(); let state = other.lock().all_watch_states().unwrap();
assert_eq!(state.len(), 2, "both episodes survive: {state:?}"); assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
} }
@@ -832,6 +900,7 @@ mod tests {
version: FORMAT_VERSION + 1, version: FORMAT_VERSION + 1,
installation: "future".to_owned(), installation: "future".to_owned(),
entries: vec![entry("ep1", 60.0, 100)], entries: vec![entry("ep1", 60.0, 100)],
registers: Registers::new(),
}; };
drive.put("future.json", serde_json::to_vec(&future).unwrap()); drive.put("future.json", serde_json::to_vec(&future).unwrap());
let (laptop, catalog) = device("laptop"); let (laptop, catalog) = device("laptop");
@@ -841,7 +910,7 @@ mod tests {
.unwrap(); .unwrap();
let report = laptop let report = laptop
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY) .sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
.await .await
.unwrap(); .unwrap();
@@ -860,21 +929,24 @@ mod tests {
.unwrap(); .unwrap();
drive.at(100); drive.at(100);
old_phone old_phone
.sync_with(&drive, &old_catalog, 100) .sync_with(&drive, &old_catalog.profile(), 100)
.await .await
.unwrap(); .unwrap();
let (phone, catalog) = device("phone"); let (phone, catalog) = device("phone");
let later = 100 + RETIRE_AFTER_SECS; let later = 100 + RETIRE_AFTER_SECS;
drive.at(later); 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!(report.retired, 1);
assert_eq!(drive.names(), ["phone.json"]); assert_eq!(drive.names(), ["phone.json"]);
// Nothing was lost: the newcomer's file carries it now. // Nothing was lost: the newcomer's file carries it now.
let (fresh, fresh_catalog) = device("fresh"); let (fresh, fresh_catalog) = device("fresh");
fresh fresh
.sync_with(&drive, &fresh_catalog, later) .sync_with(&drive, &fresh_catalog.profile(), later)
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -893,13 +965,13 @@ mod tests {
.unwrap(); .unwrap();
drive.at(100); drive.at(100);
laptop laptop
.sync_with(&drive, &laptop_catalog, 100) .sync_with(&drive, &laptop_catalog.profile(), 100)
.await .await
.unwrap(); .unwrap();
let (phone, catalog) = device("phone"); let (phone, catalog) = device("phone");
let report = phone let report = phone
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY) .sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
.await .await
.unwrap(); .unwrap();
@@ -925,9 +997,13 @@ mod tests {
version: FORMAT_VERSION, version: FORMAT_VERSION,
installation: "abc".to_owned(), installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)], entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
}; };
let bytes = serde_json::to_vec(&file).unwrap(); 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] #[test]
@@ -936,6 +1012,7 @@ mod tests {
version: FORMAT_VERSION + 1, version: FORMAT_VERSION + 1,
installation: "abc".to_owned(), installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)], entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
}; };
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None); assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
assert_eq!(parse(b"not json"), None); assert_eq!(parse(b"not json"), None);
+5 -5
View File
@@ -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 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_*` 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, 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 — download cancellation/resume, and an offline launch with networking disabled —
is `scripts/android-acceptance.sh`; `--list` prints every case and what it 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, 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 given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
reach the device as instrumentation arguments and are stored nowhere. Without reach the device as instrumentation arguments and are stored nowhere. Without
them those cases report `skip`. `picture-in-picture` is still `pending`, so the them those cases report `skip`. There is no Picture-in-Picture: it was removed
run tells you the matrix is incomplete — but no longer because of B40, which is after a PiP window that never sized its video right proved less use than
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
detail, including why lint runs against a baseline. detail, including why lint runs against a baseline.
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not 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 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 feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
memory. `PlaybackService` owns the mpv core for background audio and media 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 Do not publish an Android binary until this exact pipeline is verified on real
arm64 hardware and an x86_64 emulator. arm64 hardware and an x86_64 emulator.
+14 -1
View File
@@ -118,6 +118,18 @@ an edge case.
including trashing the file of an installation quiet for 90 days once its 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 history is merged into this one's. Folders of the account added as shares
are only ever read. 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 - 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 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, 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
+6 -1
View File
@@ -75,10 +75,15 @@ 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 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 comes forward. "Download on Wi-Fi only" stops transfers on a cellular or
otherwise expensive network rather than holding them back. 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. 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 - **Background audio off pauses** the episode when the app leaves the screen, where
Android stops it. Android stops it.
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
stand in for the ripple, and a settings page goes back by an edge strip of its
own, as a title page does, where Android has the system back.
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
secrets are this-device-only Keychain items, so a restored share list would secrets are this-device-only Keychain items, so a restored share list would
arrive without them, and a restored `sync.json` would give two devices one sync arrive without them, and a restored `sync.json` would give two devices one sync
+3 -4
View File
@@ -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 | | `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 | | `skip` | needs a share that was not configured |
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
`pending` against B40 — asserting today's behaviour would encode the bug rather would encode the bug rather than catch it. The `picture-in-picture` case went
than catch it. **The matrix is not complete, and the runner says so on every with Picture-in-Picture itself, so nothing is `pending`.
run.**
The five live cases need a share, given as environment variables that reach the The five live cases need a share, given as environment variables that reach the
device as instrumentation arguments and are written nowhere: device as instrumentation arguments and are written nowhere:
+282 -113
View File
@@ -52,12 +52,21 @@ final class AppModel {
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0) var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
/// What the snackbar says. /// What the snackbar says.
var message: String? 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. /// What Undo on `message` puts back, when the message offers one.
var undo: [WatchSnapshot]? var undo: [WatchSnapshot]?
var account = AccountUiState() var account = AccountUiState()
@ObservationIgnored private var searchTask: Task<Void, Never>? @ObservationIgnored private var searchTask: Task<Void, Never>?
@ObservationIgnored private var started = false @ObservationIgnored private var started = false
/// Launch and the first `.active` both ask; one crawl answers.
@ObservationIgnored private var catchingUp = false
/// Every banner line whose work is still running, oldest first.
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
@ObservationIgnored private var nextActivity = 0
/// Five minutes, as on Android: cheap when nothing changed — one folder /// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload. /// listing, and no upload.
@@ -67,6 +76,7 @@ final class AppModel {
if started { return } if started { return }
started = true started = true
reload() reload()
enteredForeground()
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() } DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued() DownloadCoordinator.shared.resumeQueued()
Task { Task {
@@ -126,46 +136,120 @@ final class AppModel {
/// The crawl itself, for pull to refresh, which holds its spinner until /// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns. /// this returns.
func crawlNow(_ shareId: String?) async { func crawlNow(_ shareId: String?) async {
refreshing = true let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
message = nil await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
do { message = nil
try await NativeRuntime.engine().crawl(shareId: shareId) do {
} catch { try await NativeRuntime.engine().crawl(shareId: shareId)
message = errorMessage(error) } catch {
message = errorMessage(error)
}
}
await reloadNow()
// Not awaited: pull to refresh lets go once the crawl is in, and the
// banner carries the lookup.
Task { await matchAfterCrawl() }
}
/// Look up whatever a crawl brought that has no answer yet: a new show, or
/// a new episode its stored guide cannot name. When everything already
/// has one the bridge asks nothing, so this is cheap after every crawl.
private func matchAfterCrawl() async {
if metadataSettings.enabled, metadataSettings.ready {
await match(force: false, quiet: true)
} }
refreshing = false
reload()
} }
/// Run enrichment again. With `force` every title is looked up afresh, /// Run enrichment again. With `force` every title is looked up afresh,
/// including ones that already matched. /// including ones that already matched.
func matchTitles(force: Bool) { func matchTitles(force: Bool) {
Task { Task { await match(force: force) }
refreshing = true }
message = nil
/// With `quiet`, a run nobody asked for says nothing when it found
/// nothing to do.
private func match(force: Bool, quiet: Bool = false) async {
await busy("Looking up titles…") {
do { do {
let summary = try await NativeRuntime.engine().matchTitles(force: force) let summary = try await NativeRuntime.engine().matchTitles(force: force)
message = Self.describe(summary) let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
if !quiet || found {
message = Self.describe(summary)
}
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
refreshing = false
reload()
} }
reload()
}
/// Say `what` in the activity banner while `work` runs.
///
/// The banner shows the newest line still running. Work overlaps — a sync
/// starts during a crawl and ends after it — so each line is removed by
/// itself rather than by putting back whatever was showing before, which
/// once brought back a finished crawl's line and spun it forever.
private func busy(_ what: String, _ work: () async -> Void) async {
let token = nextActivity
nextActivity += 1
activities.append((token, what))
showActivity()
await work()
activities.removeAll { $0.token == token }
showActivity()
}
private func showActivity() {
activity = activities.last?.line
refreshing = activity != nil
} }
// MARK: - Shares // MARK: - Shares
func addShare(name: String, url: String, password: String?) { /// Add a link, and return why it was refused, if it was.
Task { ///
do { /// The link is opened before anything is stored, so the form can stay up
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } /// with the reason under it and a retry is a retry — not "already in the
_ = try await run { try $0.addShare(name: name, url: url, customPassword: password) } /// library" for a share that never opened. Only the new share is crawled:
try await NativeRuntime.engine().crawl(shareId: nil) /// another share that fails to open must not make this one look broken.
} catch { func addShare(name: String, url: String, password: String?) async -> String? {
message = errorMessage(error) 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
}
await matchAfterCrawl()
if announce {
let added = Set(titles.map(\.key)).subtracting(before).count
message = added == 1 ? "1 title added" : "\(added) titles added"
} }
} }
@@ -174,34 +258,41 @@ final class AppModel {
/// offline files alone. /// offline files alone.
func repairShare(id: String, url: String, password: String?) { func repairShare(id: String, url: String, password: String?) {
Task { Task {
do { let name = shares.first { $0.id == id }?.name ?? "the share"
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } await busy("Reading \(name) again…") {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } do {
try await NativeRuntime.engine().crawl(shareId: id) let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
} catch { _ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
reportError(error) try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
}
await reloadNow()
} }
reload() await matchAfterCrawl()
} }
} }
func removeShare(_ id: String) { func removeShare(_ id: String) {
Task { Task {
do { let name = shares.first { $0.id == id }?.name ?? "the share"
// Wait for the transfers to stop before Rust removes catalog await busy("Removing \(name)…") {
// and files. Rust also rejects any late publication. do {
await DownloadCoordinator.shared.cancelShare(id) // Wait for the transfers to stop before Rust removes catalog
let store = DownloadStateStore.shared // and files. Rust also rejects any late publication.
let engine = try await NativeRuntime.engine() await DownloadCoordinator.shared.cancelShare(id)
for download in store.records() where download.shareId == id { let store = DownloadStateStore.shared
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId) let engine = try await NativeRuntime.engine()
for download in store.records() where download.shareId == id {
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
}
store.removeShare(id)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
} }
store.removeShare(id) await reloadNow()
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
} }
reload()
} }
} }
@@ -270,29 +361,33 @@ final class AppModel {
func signOut() { func signOut() {
Task { Task {
do { await busy("Signing out…") {
try await NativeRuntime.engine().signOut() do {
} catch { try await NativeRuntime.engine().signOut()
reportError(error) } catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
await reloadNow()
} }
account = AccountUiState(state: .signedOut)
reload()
} }
} }
/// Sync now, from a tap: says so when it is done. /// Sync now, from a tap: says so when it is done.
func syncNow() { func syncNow() {
Task { 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 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 /// Pull other devices' positions, shares and settings in and push this
/// on a timer, and a failure is shown on the account card rather than as a /// one's out. Quiet: it runs on a timer, and a failure is shown on the
/// snackbar every five minutes of an offline evening. Returns whether it /// account card rather than as a snackbar every five minutes of an offline
/// synced. /// evening. Returns whether it synced.
@discardableResult @discardableResult
func syncWatchHistory() async -> Bool { func syncWatchHistory() async -> Bool {
guard case .signedIn = account.state else { return false } guard case .signedIn = account.state else { return false }
@@ -301,7 +396,7 @@ final class AppModel {
account.syncedAt = Date() account.syncedAt = Date()
account.applied = Int(report.applied) account.applied = Int(report.applied)
account.syncError = nil account.syncError = nil
if report.applied > 0 { reload() } await takeSynced(report)
return true return true
} catch { } catch {
let state = try? await run { $0.accountState() } let state = try? await run { $0.accountState() }
@@ -314,6 +409,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 /// Leaving the app: push where playback stopped, inside the grace iOS
/// gives a backgrounded app — what `WatchSyncWorker` does on Android. /// gives a backgrounded app — what `WatchSyncWorker` does on Android.
func syncInBackground() { func syncInBackground() {
@@ -333,15 +462,13 @@ final class AppModel {
func addAccountFolder(name: String, volumeId: String, linkId: String) { func addAccountFolder(name: String, volumeId: String, linkId: String) {
Task { Task {
refreshing = true
do { do {
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) } 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 { } catch {
reportError(error) reportError(error)
} }
refreshing = false
reload()
} }
} }
@@ -351,6 +478,7 @@ final class AppModel {
/// title's positions are what this throws away, and a mis-tap on a tile's /// title's positions are what this throws away, and a mis-tap on a tile's
/// menu should not cost them. /// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
showWatched(title.playlist, watched)
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs let duration = before?.durationSecs
try engine.saveWatchState( try engine.saveWatchState(
@@ -437,24 +565,29 @@ final class AppModel {
// MARK: - Metadata // MARK: - Metadata
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) { /// Store the enrichment settings, and return why not, if they were not.
Task { ///
do { /// The page shows the new settings as soon as they are stored; the match
let engine = try await NativeRuntime.engine() /// run that follows can take minutes and says so in the activity banner.
try await run { engine in /// Before, the page only changed once that run had finished, so a save
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty { /// looked like it had done nothing.
try engine.setMetadataApiKey(provider: provider, key: apiKey) func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
} do {
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true)) metadataSettings = try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: provider, key: apiKey)
} }
if enabled { try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
message = Self.describe(try await engine.matchTitles(force: false)) return try engine.metadataSettings()
}
} catch {
reportError(error)
} }
reload() } catch {
return errorMessage(error)
} }
await reloadNow()
if enabled {
Task { await match(force: false) }
}
return nil
} }
func reloadAfterMetadataChange() { reload() } func reloadAfterMetadataChange() { reload() }
@@ -476,12 +609,14 @@ final class AppModel {
func removeOffline(_ file: OfflineRecord) { func removeOffline(_ file: OfflineRecord) {
Task { Task {
do { await busy("Deleting the download…") {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId) do {
} catch { try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
message = errorMessage(error) } catch {
message = errorMessage(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -504,14 +639,16 @@ final class AppModel {
func removeAllOffline() { func removeAllOffline() {
Task { Task {
await DownloadCoordinator.shared.cancelAll() await busy("Deleting every download…") {
do { await DownloadCoordinator.shared.cancelAll()
try await NativeRuntime.engine().removeAllOffline() do {
DownloadStateStore.shared.clear() try await NativeRuntime.engine().removeAllOffline()
} catch { DownloadStateStore.shared.clear()
reportError(error) } catch {
reportError(error)
}
await reloadNow()
} }
reload()
} }
} }
@@ -556,6 +693,7 @@ final class AppModel {
/// the position at its duration where one is known, so the progress bar /// the position at its duration where one is known, so the progress bar
/// agrees with the tick. /// agrees with the tick.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
showWatched([episode], watched)
Task { Task {
do { do {
try await run { engine in try await run { engine in
@@ -575,39 +713,70 @@ final class AppModel {
} }
} }
/// Draw a watched mark the moment it is tapped, before the bridge has
/// stored it. The reload that shows the stored state is a second away on
/// an old iPad, and a tick that does not appear gets tapped again. That
/// reload still follows, and puts back whatever was really stored.
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
titles = titles.map { title in
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
var title = title
title.seasons = title.seasons.map { season in
var season = season
season.episodes = season.episodes.map { episode in
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
var episode = episode
episode.watched = watched
if !watched {
episode.progress = nil
episode.resumeAt = nil
}
return episode
}
return season
}
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
return title
}
}
// MARK: - Reload // MARK: - Reload
func 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 query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let sort = sort let sort = sort
let grouped = grouped let grouped = grouped
Task { do {
do { let result = try await run { engine in
let result = try await run { engine in // Here rather than inside `library()`, which is read-only
// Here rather than inside `library()`, which is read-only // and cached: a full reload is the one place that has
// and cached: a full reload is the one place that has // already paid for a walk of the offline files.
// already paid for a walk of the offline files. _ = try engine.pruneOfflineFiles()
_ = try engine.pruneOfflineFiles() return Reloaded(
return Reloaded( shares: try engine.shares(),
shares: try engine.shares(), titles: try engine.library(search: query),
titles: try engine.library(search: query), arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped), offline: try engine.offlineFiles(),
offline: try engine.offlineFiles(), metadataSettings: try engine.metadataSettings(),
metadataSettings: try engine.metadataSettings(), storage: try engine.storageUsage()
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)
} }
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)
} }
} }
+37 -5
View File
@@ -99,8 +99,12 @@ struct RootView: View {
} }
} }
} }
Snackbar() VStack(spacing: 8) {
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0)) 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()) .background(scheme.background.ignoresSafeArea())
} }
@@ -140,6 +144,7 @@ struct RootView: View {
model.syncInBackground() model.syncInBackground()
case .active: case .active:
host.enteredForeground() host.enteredForeground()
model.enteredForeground()
DownloadCoordinator.shared.resumeQueued() DownloadCoordinator.shared.resumeQueued()
default: default:
break break
@@ -257,7 +262,7 @@ private struct NavigationBar: View {
.padding(.bottom, 6) .padding(.bottom, 6)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -290,7 +295,7 @@ private struct NavigationRail: View {
.frame(width: 80) .frame(width: 80)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityLabel(item.label) .accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
@@ -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 /// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back. /// be taken back.
private struct Snackbar: View { private struct Snackbar: View {
@@ -318,7 +350,7 @@ private struct Snackbar: View {
Button("Undo", action: model.performUndo) Button("Undo", action: model.performUndo)
.textStyle(.labelLarge) .textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary) .foregroundStyle(scheme.inversePrimary)
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(.horizontal, 8) .padding(.horizontal, 8)
} }
} }
+3
View File
@@ -109,6 +109,9 @@ final class MpvPlayer {
private init() { private init() {
layer.framebufferOnly = true layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor 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 } guard let mpv = mpv_create() else { return }
// mpv takes the layer's address as an int64, not a Swift reference. // mpv takes the layer's address as an int64, not a Swift reference.
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque())) var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
+16 -2
View File
@@ -77,6 +77,10 @@ final class PlayerHost {
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil) private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
/// What stopped this episode, until the viewer dismisses it. /// What stopped this episode, until the viewer dismisses it.
var playbackError: String? var playbackError: String?
/// The stream is being opened: the share session, the revision and its
/// block list, all before mpv has a byte. On an old iPad that is seconds
/// of a black screen unless something says so.
private(set) var opening = false
/// Read once per episode: a preference the viewer changes mid-episode /// Read once per episode: a preference the viewer changes mid-episode
/// applies to the next one. /// applies to the next one.
private(set) var autoSkip = false private(set) var autoSkip = false
@@ -107,6 +111,11 @@ final class PlayerHost {
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil } var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
var key: String? { episode.map(mediaKey) } var key: String? { episode.map(mediaKey) }
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil } var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
/// Whether mpv's readings — position, chapters — are the open episode's.
/// Not while the next one opens: the last one's credits go on playing
/// until it is loaded, and read against the new index they offered the
/// episode *after* the next as up next.
var playingOpenEpisode: Bool { player?.media != nil && player?.media == key }
var isOpen: Bool { title != nil } var isOpen: Bool { title != nil }
// MARK: - Opening // MARK: - Opening
@@ -177,6 +186,7 @@ final class PlayerHost {
index = 0 index = 0
minimized = false minimized = false
playbackError = nil playbackError = nil
opening = false
chapters = ChapterPlan(entries: [], creditsStart: nil) chapters = ChapterPlan(entries: [], creditsStart: nil)
OrientationLock.set(false) OrientationLock.set(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
@@ -210,6 +220,7 @@ final class PlayerHost {
return return
} }
try? AVAudioSession.sharedInstance().setActive(true) try? AVAudioSession.sharedInstance().setActive(true)
opening = true
loadTask = Task { loadTask = Task {
do { do {
try await session.open() try await session.open()
@@ -244,6 +255,8 @@ final class PlayerHost {
hardwareDecoding: SettingsStore().hardwareDecoding hardwareDecoding: SettingsStore().hardwareDecoding
) )
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") } guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
// mpv's own buffering takes over the spinner from here.
opening = false
session.transferToPlayer() session.transferToPlayer()
// After the load, because mpv keeps none of the three across one. // After the load, because mpv keeps none of the three across one.
player.setVolume(global.volume) player.setVolume(global.volume)
@@ -256,6 +269,7 @@ final class PlayerHost {
abandon(session) abandon(session)
return return
} }
opening = false
playbackError = errorMessage(error) playbackError = errorMessage(error)
} }
} }
@@ -320,7 +334,7 @@ final class PlayerHost {
} }
private func tick() { private func tick() {
guard let player else { return } guard let player, playingOpenEpisode else { return }
replanChapters(player) replanChapters(player)
// Auto-skip, once per chapter. // Auto-skip, once per chapter.
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position), if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
@@ -343,7 +357,7 @@ final class PlayerHost {
/// The skip button's offer at the current position, if any. /// The skip button's offer at the current position, if any.
var offer: SkipOffer? { var offer: SkipOffer? {
guard let player else { return nil } guard let player, playingOpenEpisode else { return nil }
return skipOffer(plan: chapters, position: player.position) return skipOffer(plan: chapters, position: player.position)
} }
+28 -9
View File
@@ -63,7 +63,8 @@ struct PlayerView: View {
} }
UpNextCard( UpNextCard(
next: host.next, next: host.next,
visible: host.autoplay && !player.paused && host.chapters.creditsStart.map { player.position >= $0 } == true, visible: host.autoplay && !player.paused && host.playingOpenEpisode
&& host.chapters.creditsStart.map { player.position >= $0 } == true,
onPlayNow: host.advance onPlayNow: host.advance
) )
} }
@@ -73,10 +74,12 @@ struct PlayerView: View {
// Only while nothing else explains the still picture: an error // Only while nothing else explains the still picture: an error
// is the more useful thing to say. // is the more useful thing to say.
if player.stalled && host.playbackError == nil { if (host.opening || player.stalled) && host.playbackError == nil {
VStack(spacing: 12) { VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(scheme.primary) ProgressView().controlSize(.large).tint(scheme.primary)
if player.buffering { if host.opening {
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
} else if player.buffering {
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…") Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface) .textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
} }
@@ -295,7 +298,7 @@ private struct CompactIcon: View {
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(PressableStyle(circle: true, wash: .white))
.disabled(!enabled) .disabled(!enabled)
.accessibilityLabel(label) .accessibilityLabel(label)
} }
@@ -342,7 +345,7 @@ private struct UpNextCard: View {
} }
.clipShape(RoundedRectangle(cornerRadius: Corner.standard)) .clipShape(RoundedRectangle(cornerRadius: Corner.standard))
} }
.buttonStyle(.plain) .buttonStyle(PressableStyle(wash: .white))
.accessibilityLabel("Play now") .accessibilityLabel("Play now")
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant) Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
@@ -515,7 +518,7 @@ private struct EpisodeSheet: View {
.background(current ? scheme.secondaryContainer : .clear) .background(current ? scheme.secondaryContainer : .clear)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
@@ -567,9 +570,9 @@ struct MpvView: UIViewRepresentable {
func updateUIView(_: LayerView, context _: Context) {} func updateUIView(_: LayerView, context _: Context) {}
final class LayerView: UIView { final class LayerView: UIView {
private let video: CALayer private let video: CAMetalLayer
init(layer video: CALayer) { init(layer video: CAMetalLayer) {
self.video = video self.video = video
super.init(frame: .zero) super.init(frame: .zero)
backgroundColor = .black backgroundColor = .black
@@ -580,14 +583,30 @@ struct MpvView: UIViewRepresentable {
@available(*, unavailable) @available(*, unavailable)
required init?(coder _: NSCoder) { nil } 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() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
video.frame = bounds 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() 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()
}
} }
} }
+4 -4
View File
@@ -44,7 +44,7 @@ struct AccountCard: View {
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface) 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) .textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
Button("Sign in", action: onSignIn).buttonStyle(.tonal) 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. /// "Synced 3 min ago · 2 from other devices", or why it did not.
func syncLine(_ account: AccountUiState, now: Date) -> String { func syncLine(_ account: AccountUiState, now: Date) -> String {
if let error = account.syncError { return "Watch history did not sync: \(error)" } if let error = account.syncError { return "Did not sync: \(error)" }
guard let at = account.syncedAt else { return "Watch history syncs with your Drive" } 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 minutes = Int(max(now.timeIntervalSince(at), 0) / 60)
let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago" 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" : "" let applied = account.applied > 0 ? " · \(account.applied) from other devices" : ""
@@ -383,7 +383,7 @@ private struct DriveRow: View {
.padding(.vertical, 12) .padding(.vertical, 12)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.disabled(open == nil) .disabled(open == nil)
} }
} }
+1 -1
View File
@@ -69,7 +69,7 @@ struct IconButton: View {
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressableIcon)
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38)) .foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
.disabled(!enabled) .disabled(!enabled)
.accessibilityLabel(label) .accessibilityLabel(label)
+2 -2
View File
@@ -57,7 +57,7 @@ struct LanguageSetting: View {
Button { choosing = true } label: { Button { choosing = true } label: {
ListRow(headline: headline, supporting: languageLabel(value)) ListRow(headline: headline, supporting: languageLabel(value))
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.sheet(isPresented: $choosing) { .sheet(isPresented: $choosing) {
LanguageSheet(headline: headline, value: value) { tag in LanguageSheet(headline: headline, value: value) { tag in
choosing = false choosing = false
@@ -114,7 +114,7 @@ private struct LanguageSheet: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.accessibilityAddTraits(selected ? .isSelected : []) .accessibilityAddTraits(selected ? .isSelected : [])
} }
} }
+19 -14
View File
@@ -55,6 +55,9 @@ struct LibraryView: View {
@State private var menuFor: TitleRecord? @State private var menuFor: TitleRecord?
@State private var menuResume: Resumable? @State private var menuResume: Resumable?
@State private var matching: TitleRecord? @State private var matching: TitleRecord?
/// "Change match" picked in the tile menu, waiting for that menu to finish
/// leaving: a sheet presented while another is still dismissing is dropped.
@State private var pendingMatch: TitleRecord?
// Search is an icon until it is wanted: a permanent field above the // Search is an icon until it is wanted: a permanent field above the
// library spent a band of every visit on something used in few of them. // library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false @SceneStorage("library.searching") private var searching = false
@@ -90,7 +93,15 @@ struct LibraryView: View {
content(resumable) content(resumable)
} }
.background(scheme.background) .background(scheme.background)
.sheet(item: $menuFor) { title in .sheet(item: $menuFor, onDismiss: {
// After the dismissal rather than a guessed delay: on a slow iPad
// the menu was still leaving after 350 ms, the search sheet was
// dropped, and it turned up later on some unrelated redraw.
if let title = pendingMatch {
pendingMatch = nil
matching = title
}
}) { title in
let playlist = title.playlist let playlist = title.playlist
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
TileMenu(heading: title.displayName, caption: title.caption) { TileMenu(heading: title.displayName, caption: title.caption) {
@@ -104,12 +115,7 @@ struct LibraryView: View {
} }
// The same search the title page offers, for the tile that is // The same search the title page offers, for the tile that is
// plainly wrong — the wrong poster is what the grid shows. // plainly wrong — the wrong poster is what the grid shows.
MenuRow("pencil", "Change match") { MenuRow("pencil", "Change match") { pendingMatch = title }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
matching = title
}
}
} }
} }
.sheet(item: $menuResume) { entry in .sheet(item: $menuResume) { entry in
@@ -183,8 +189,9 @@ struct LibraryView: View {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(resumable) { entry in ForEach(resumable) { entry in
ContinueCard(entry: entry) ContinueCard(entry: entry)
.onTapGesture { onResume(entry.title, entry.index) } .tapAndHold { onResume(entry.title, entry.index) } onHold: {
.onLongPressGesture { longPressed { menuResume = entry } } longPressed { menuResume = entry }
}
} }
} }
} }
@@ -199,8 +206,7 @@ struct LibraryView: View {
ForEach(shelf.titles, id: \.key) { title in ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title) PosterTile(title: title)
.frame(width: 112) .frame(width: 112)
.onTapGesture { onTitle(title) } .tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
.onLongPressGesture { longPressed { menuFor = title } }
} }
} }
} }
@@ -214,8 +220,7 @@ struct LibraryView: View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
ForEach(tiles) { tile in ForEach(tiles) { tile in
PosterTile(title: tile.title, folded: tile.folded) PosterTile(title: tile.title, folded: tile.folded)
.onTapGesture { onTitle(tile.title) } .tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
.onLongPressGesture { longPressed { menuFor = tile.title } }
} }
} }
} }
@@ -357,7 +362,7 @@ struct MenuRow: View {
} label: { } label: {
ListRow(icon: icon, headline: label) ListRow(icon: icon, headline: label)
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(.horizontal, 8) .padding(.horizontal, 8)
} }
} }
+7 -2
View File
@@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable {
// A redirect must not leave HTTPS. // A redirect must not leave HTTPS.
http.url?.scheme?.lowercased() == "https" http.url?.scheme?.lowercased() == "https"
else { return nil } else { return nil }
var bytes = Data() // Refused before reading when the server says up front it is too big.
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
// An array sized once, not a `Data` grown a byte at a time: on an old
// iPad, a screen of posters read that way was seconds of CPU.
var bytes = [UInt8]()
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
do { do {
for try await byte in stream { for try await byte in stream {
bytes.append(byte) bytes.append(byte)
@@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable {
} catch { } catch {
return nil return nil
} }
return bytes return Data(bytes)
} }
/// Decode within a bound the display can actually use: the compressed size /// Decode within a bound the display can actually use: the compressed size
+93 -32
View File
@@ -42,6 +42,8 @@ struct SettingsView: View {
@State private var prefs: PlaybackPrefsRecord? @State private var prefs: PlaybackPrefsRecord?
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var showMetadata = false @State private var showMetadata = false
/// How far an edge swipe back has gone, 0 to 1.
@State private var backProgress: CGFloat = 0
@State private var legalDocument: LegalDocument? @State private var legalDocument: LegalDocument?
var body: some View { var body: some View {
@@ -56,15 +58,22 @@ struct SettingsView: View {
Button { page = entry } label: { Button { page = entry } label: {
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry)) ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
} }
} }
} }
} }
.task { // Read again whenever the list is shown, not once: sync can rewrite
prefs = try? await model.run { try $0.playbackPrefs() } // 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) { .alert("Delete all offline episodes?", isPresented: $confirmDelete) {
Button("Delete", role: .destructive, action: model.removeAllOffline) Button("Delete", role: .destructive, action: model.removeAllOffline)
@@ -75,8 +84,7 @@ struct SettingsView: View {
.sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) } .sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) }
.sheet(isPresented: $showMetadata) { .sheet(isPresented: $showMetadata) {
MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in
model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key) await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
showMetadata = false
} }
} }
} }
@@ -109,13 +117,27 @@ struct SettingsView: View {
.padding(.bottom, 24) .padding(.bottom, 24)
} }
} }
// Back by an edge swipe, as from a title page. .scaleEffect(1 - 0.1 * backProgress)
.gesture( .clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
DragGesture(minimumDistance: 20) // Back by an edge swipe, as from a title page — from a strip of its
.onEnded { drag in // own, as there. On the whole page the swipe began on whichever row
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil } // was under the edge, and lifting the finger pressed it: the way back
} // from Metadata opened the enrichment sheet.
) .overlay(alignment: .leading) {
Color.clear
.frame(width: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
.onEnded { drag in
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
self.page = nil
}
withAnimation(.spring) { backProgress = 0 }
}
)
}
} }
@ViewBuilder private var playback: some View { @ViewBuilder private var playback: some View {
@@ -216,12 +238,23 @@ struct SettingsView: View {
Binding(get: { value }, set: { on in update { change(&$0, on) } }) 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) { private func update(_ change: (inout PlaybackPrefsRecord) -> Void) {
guard var next = prefs else { return } guard var next = prefs else { return }
let before = next
change(&next) change(&next)
prefs = next prefs = next
let stored = 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)
}
}
} }
} }
@@ -251,22 +284,25 @@ private struct SettingAction: View {
var body: some View { var body: some View {
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) } Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
.buttonStyle(.plain) .buttonStyle(.pressable)
.disabled(!enabled) .disabled(!enabled)
} }
} }
private struct MetadataSettingsSheet: View { private struct MetadataSettingsSheet: View {
let current: MetadataSettingsRecord 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(\.dismiss) private var dismiss
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@State private var enabled: Bool @State private var enabled: Bool
@State private var provider: MetadataProvider @State private var provider: MetadataProvider
@State private var language: String @State private var language: String
@State private var apiKey = "" @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.current = current
self.onSave = onSave self.onSave = onSave
_enabled = State(initialValue: current.enabled) _enabled = State(initialValue: current.enabled)
@@ -290,14 +326,27 @@ private struct MetadataSettingsSheet: View {
Field(label: "Language", text: $language) Field(label: "Language", text: $language)
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true) 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: { } buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal) Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button("Save") { Button(saving ? "Saving…" : "Save") {
onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey) 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) .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() .secureContent()
} }
} }
@@ -408,9 +457,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) { private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) {
guard var next = choice else { return } guard var next = choice else { return }
edit(&next) edit(&next)
choice = next
let chosen = next let chosen = next
Task { Task {
await repaint(chosen, store: true) await repaint(chosen, store: true)
@@ -420,20 +472,29 @@ private struct AppearancePicker: View {
private func repaint(_ next: AppearanceRecord, store: Bool) async { private func repaint(_ next: AppearanceRecord, store: Bool) async {
let accents = Self.accents let accents = Self.accents
guard let (palette, row) = try? await model.run({ engine -> (PaletteRecord, [AccentChoice: UInt32]) in let resolved: (PaletteRecord, [AccentChoice: UInt32])
if store { try engine.setAppearance(appearance: next) } do {
let palette = engine.previewPalette(appearance: next) resolved = try await model.run { engine -> (PaletteRecord, [AccentChoice: UInt32]) in
// Every accent as it would look in *this* flavour: a swatch row that if store { try engine.setAppearance(appearance: next) }
// keeps Mocha's pastels while Latte is selected lies about what the let palette = engine.previewPalette(appearance: next)
// next tap does. // Every accent as it would look in *this* flavour: a swatch row that
var row: [AccentChoice: UInt32] = [:] // keeps Mocha's pastels while Latte is selected lies about what the
for accent in accents { // next tap does.
var variant = next var row: [AccentChoice: UInt32] = [:]
variant.accent = accent for accent in accents {
row[accent] = engine.previewPalette(appearance: variant).accent var variant = next
variant.accent = accent
row[accent] = engine.previewPalette(appearance: variant).accent
}
return (palette, row)
} }
return (palette, row) } catch {
}) else { return } 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 choice = next
swatches = row.mapValues(argb) swatches = row.mapValues(argb)
AppearanceState.shared.apply(palette) AppearanceState.shared.apply(palette)
+33 -8
View File
@@ -51,13 +51,13 @@ struct SharesView: View {
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16)) .background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.3), radius: 6, y: 3) .shadow(color: .black.opacity(0.3), radius: 6, y: 3)
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
.padding(16) .padding(16)
} }
} }
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) { .sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in 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( .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 { 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(\.dismiss) private var dismiss
@Environment(\.scheme) private var scheme
@State private var name = "" @State private var name = ""
@State private var url: String @State private var url: String
@State private var password = "" @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 self.onAdd = onAdd
_url = State(initialValue: initialUrl) _url = State(initialValue: initialUrl)
} }
private var ready: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View { var body: some View {
DialogSheet("Add Proton Drive share") { DialogSheet("Add Proton Drive share") {
Field(label: "Library name", text: $name) Field(label: "Library name", text: $name)
@@ -195,15 +206,29 @@ private struct AddShareSheet: View {
.buttonStyle(.quiet) .buttonStyle(.quiet)
} }
Field(label: "Custom password (optional)", text: $password, secret: true) 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: { } buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal) Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button("Add") { Button(adding ? "Adding…" : "Add") {
onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password) adding = true
dismiss() refusal = nil
Task {
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
adding = false
if refusal == nil { dismiss() }
}
} }
.buttonStyle(.accent) .buttonStyle(.accent)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(!ready || adding)
} }
.interactiveDismissDisabled(adding)
.secureContent() .secureContent()
} }
} }
+59
View File
@@ -67,6 +67,65 @@ extension ButtonStyle where Self == ProtonButtonStyle {
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) } static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
} }
/// A button with no chrome of its own — an icon, a row, a chip — that still
/// shows it was pressed: a little smaller, with a wash behind it.
///
/// `.plain` shows nothing at all, and on a slow device, where what the press
/// does can take a second to appear, a press that shows nothing reads as one
/// that missed — and gets pressed again.
struct PressableStyle: ButtonStyle {
var circle = false
/// The wash's colour; the scheme's text colour when nil. White over video.
var wash: Color?
@Environment(\.scheme) private var scheme
func makeBody(configuration: Configuration) -> some View {
let pressed = configuration.isPressed
let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0)
configuration.label
.background {
if circle {
Circle().fill(fill)
} else {
RoundedRectangle(cornerRadius: Corner.standard).fill(fill)
}
}
.scaleEffect(pressed ? 0.96 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
}
}
extension ButtonStyle where Self == PressableStyle {
static var pressable: PressableStyle { PressableStyle() }
static var pressableIcon: PressableStyle { PressableStyle(circle: true) }
}
/// A tap and a long press on something that is not a button — a poster, a
/// card, an episode row — showing the press the moment the finger lands, as
/// `PressableStyle` does for a button.
private struct TapAndHold: ViewModifier {
let onTap: () -> Void
let onHold: (() -> Void)?
@State private var pressed = false
func body(content: Content) -> some View {
content
.scaleEffect(pressed ? 0.96 : 1)
.opacity(pressed ? 0.8 : 1)
.animation(.easeOut(duration: 0.1), value: pressed)
.onTapGesture(perform: onTap)
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
}
}
extension View {
/// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with
/// the press drawn.
func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View {
modifier(TapAndHold(onTap: onTap, onHold: onHold))
}
}
/// How far along something is, drawn in the accent. /// How far along something is, drawn in the accent.
/// ///
/// `solid` is for the one bar that is deliberately not the accent: a download in /// `solid` is for the one bar that is deliberately not the accent: a download in
+7
View File
@@ -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. /// Repaint immediately, before the write that stores the choice lands.
func apply(_ palette: PaletteRecord) { func apply(_ palette: PaletteRecord) {
self.palette = palette self.palette = palette
+33 -10
View File
@@ -35,7 +35,9 @@ struct TitleView: View {
self.franchise = franchise self.franchise = franchise
self.onOpenTitle = onOpenTitle self.onOpenTitle = onOpenTitle
// One season on screen at a time, opened on the one Play would land in. // One season on screen at a time, opened on the one Play would land in.
let upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : nil let playlist = title.playlist
let nextUp = nextUpIndex(playlist)
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0)) _seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
} }
@@ -231,7 +233,7 @@ struct TitleView: View {
LazyHStack(alignment: .top, spacing: 12) { LazyHStack(alignment: .top, spacing: 12) {
ForEach(franchise, id: \.key) { member in ForEach(franchise, id: \.key) { member in
FranchiseTile(title: member, here: member.key == title.key) FranchiseTile(title: member, here: member.key == title.key)
.onTapGesture { if member.key != title.key { onOpenTitle(member) } } .tapAndHold { if member.key != title.key { onOpenTitle(member) } }
} }
} }
.padding(.horizontal, side + 16) .padding(.horizontal, side + 16)
@@ -282,7 +284,7 @@ struct FilterChip: View {
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) } if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
} }
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
@@ -341,7 +343,7 @@ private struct TitleAction: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.pressable)
} }
} }
@@ -449,7 +451,7 @@ private struct EpisodeRow: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: onPlay) .tapAndHold(onPlay)
} }
} }
@@ -465,6 +467,10 @@ struct ChangeMatchSheet: View {
// Choosing fetches the entry's episodes too, which is a few requests. // Choosing fetches the entry's episodes too, which is a few requests.
@State private var choosing = false @State private var choosing = false
@State private var options: [MatchRecord] = [] @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
@State private var forgetting = false
init(title: TitleRecord, onDone: @escaping () -> Void) { init(title: TitleRecord, onDone: @escaping () -> Void) {
self.title = title self.title = title
@@ -481,7 +487,17 @@ struct ChangeMatchSheet: View {
.buttonStyle(.accent) .buttonStyle(.accent)
.disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching) .disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching)
if choosing { 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 ForEach(options, id: \.remoteId) { option in
Button { choose(option) } label: { Button { choose(option) } label: {
@@ -497,17 +513,21 @@ struct ChangeMatchSheet: View {
.disabled(choosing) .disabled(choosing)
} }
} buttons: { } buttons: {
Button("Forget match") { Button(forgetting ? "Forgetting…" : "Forget match") {
forgetting = true
failure = nil
Task { Task {
do { do {
try await model.run { try $0.forgetMatch(titleKey: title.key) } try await model.run { try $0.forgetMatch(titleKey: title.key) }
changed() changed()
} catch { } catch {
model.reportError(error) failure = errorMessage(error)
} }
forgetting = false
} }
} }
.buttonStyle(.quiet) .buttonStyle(.quiet)
.disabled(forgetting || choosing)
Button("Close", action: onDone).buttonStyle(.tonal) Button("Close", action: onDone).buttonStyle(.tonal)
} }
} }
@@ -516,24 +536,27 @@ struct ChangeMatchSheet: View {
let term = term let term = term
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return } guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
searching = true searching = true
failure = nil
Task { Task {
do { do {
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term) options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
} catch { } catch {
model.reportError(error) failure = errorMessage(error)
} }
searching = false searching = false
searched = true
} }
} }
private func choose(_ option: MatchRecord) { private func choose(_ option: MatchRecord) {
choosing = true choosing = true
failure = nil
Task { Task {
do { do {
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option) try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
changed() changed()
} catch { } catch {
model.reportError(error) failure = errorMessage(error)
} }
choosing = false choosing = false
} }
+2 -2
View File
@@ -51,7 +51,7 @@ final class IncomingShareLinkTests: XCTestCase {
final class AccountUiTests: XCTestCase { final class AccountUiTests: XCTestCase {
func testTheSyncLineSaysHowLongAgoAndWhatCameIn() { func testTheSyncLineSaysHowLongAgoAndWhatCameIn() {
let zero = Date(timeIntervalSince1970: 0) 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(30)), "Synced just now")
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago") XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago")
XCTAssertEqual( XCTAssertEqual(
@@ -64,7 +64,7 @@ final class AccountUiTests: XCTestCase {
let zero = Date(timeIntervalSince1970: 0) let zero = Date(timeIntervalSince1970: 0)
XCTAssertEqual( XCTAssertEqual(
syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero), syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero),
"Watch history did not sync: offline" "Did not sync: offline"
) )
} }
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=1.3.0 pkgver=1.4.1
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')
-17
View File
@@ -604,23 +604,6 @@ for _name, _what, _method in (
): ):
REGISTRY.append(Case(name=_name, what=_what, run=_live(_method), needs_share=True)) 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 # runner