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

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

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

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

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

No files matched your search

@@ -85,9 +85,9 @@ data class MpvPlaybackState(
/**
* What is on screen, for the surfaces that are not the player.
*
* The media session, its notification and Picture-in-Picture all have to name
* the episode, and none of them can reach the composition that knows it. The
* player publishes this once per episode and they read it.
* The media session and its notification have to name the episode, and
* neither can reach the composition that knows it. The player publishes this
* once per episode and they read it.
*/
data class NowPlaying(
val media: String,
@@ -134,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
/**
* Where a transport outside the player sends previous/next.
*
* The player owns which episode is open, so the notification, the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
* The player owns which episode is open, so the notification and the lock
* screen cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives.
*/
var onSkipPrevious: (() -> Unit)? = null
@@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
/** Owns libmpv so audio survives Activity recreation and rotation. */
class PlaybackService : Service() {
inner class PlaybackBinder : Binder() {
val host: NativeMpvHost? get() = this@PlaybackService.host
@@ -131,18 +131,14 @@ private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
/**
* The player, as its own screen on the activity's own window.
*
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
* activity window is what makes "fullscreen" mean the whole panel, notch
* included.
* Not a dialog: a dialog window has its own insets and cannot be told to draw
* into the display cutout. Owning the activity window is what makes
* "fullscreen" mean the whole panel, notch included.
*
* [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/
@Composable
fun PlayerScreen(
@@ -150,7 +146,6 @@ fun PlayerScreen(
episodes: List<EpisodeRecord>,
index: Int,
host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean,
onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
@@ -294,9 +289,8 @@ fun PlayerScreen(
onIndexChange(target)
}
// What the media notification, the lock screen and Picture-in-Picture show.
// None of them can reach this composition, and all three have to name the
// episode rather than the app.
// What the media notification and the lock screen show. Neither can reach
// this composition, and both have to name the episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish(
NowPlaying(
@@ -359,12 +353,6 @@ fun PlayerScreen(
showControls = false
}
}
// Everything this screen draws over the picture is sized for a phone panel
// and unreadable in a thumbnail, and Android supplies its own transport
// there. Leaving PiP must find the controls up rather than mid-timeout.
val overlays = !inPictureInPicture
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
if (minimized) return
Box(
@@ -374,7 +362,6 @@ fun PlayerScreen(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = overlays,
onClick = { showControls = !showControls },
),
) {
@@ -393,7 +380,7 @@ fun PlayerScreen(
)
AnimatedVisibility(
visible = overlays && showControls,
visible = showControls,
modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(),
exit = fadeOut(),
@@ -450,7 +437,7 @@ fun PlayerScreen(
}
AnimatedVisibility(
visible = overlays && showControls,
visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut(),
@@ -476,7 +463,7 @@ fun PlayerScreen(
// Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
offer?.takeIf { !autoSkip }?.let { skip ->
TonalButton(
onClick = { nativeHost.seek(skip.target) },
modifier = Modifier
@@ -488,7 +475,7 @@ fun PlayerScreen(
UpNextCard(
next = next,
visible = overlays && autoplay && !state.paused &&
visible = autoplay && !state.paused &&
chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() },
modifier = Modifier